├── client ├── utils │ ├── index.ts │ └── color.ts ├── @extensions │ ├── png.d.ts │ └── sound.d.ts ├── constants │ ├── index.ts │ ├── ROUTE.ts │ └── COLOR.ts ├── styles │ ├── animations.ts │ ├── index.ts │ ├── slotStyle.ts │ ├── modifyStyles.ts │ └── pixelBorderStyle.ts ├── components │ ├── Charger │ │ ├── types.ts │ │ ├── styles.ts │ │ └── index.tsx │ ├── Carousel │ │ ├── types.ts │ │ ├── CarouselItem.tsx │ │ ├── CarouselNavigator │ │ │ ├── styles.ts │ │ │ └── index.tsx │ │ ├── styles.ts │ │ └── index.tsx │ ├── Loading │ │ ├── styles.ts │ │ └── index.tsx │ ├── Grid.tsx │ ├── H1.tsx │ ├── CenterizedGrid.tsx │ ├── Page.tsx │ ├── EmptySlot.tsx │ ├── Sprite │ │ ├── styles.ts │ │ └── index.tsx │ ├── Dropdown │ │ ├── styles.ts │ │ └── index.tsx │ ├── ProgressBar │ │ ├── styles.ts │ │ └── index.tsx │ ├── IntegrateInput │ │ ├── styles.ts │ │ └── index.tsx │ ├── Button.tsx │ ├── Emotion │ │ └── index.tsx │ ├── Icon.tsx │ ├── Dialog │ │ ├── styles.ts │ │ └── index.tsx │ └── Card │ │ ├── styles.ts │ │ └── index.tsx ├── atoms │ ├── version.ts │ ├── route.ts │ ├── room.ts │ ├── sound.ts │ ├── index.ts │ ├── language.ts │ ├── audioSettings.ts │ └── notification.ts ├── assets │ ├── sounds │ │ ├── music.mp3 │ │ └── effects.mp3 │ ├── sprites │ │ ├── logo.png │ │ ├── icons.png │ │ ├── emotions.png │ │ ├── box_of_cards.png │ │ ├── loading_animation.png │ │ ├── spell_animations.png │ │ └── card_content_frame.png │ └── fonts │ │ ├── dogicapixel.ttf │ │ └── VT323-Regular.ttf ├── languages │ ├── index.ts │ ├── en.ts │ └── vi.ts ├── views │ ├── Game │ │ ├── GameBoard │ │ │ ├── styles.ts │ │ │ ├── DeckCounter │ │ │ │ ├── styles.ts │ │ │ │ └── index.tsx │ │ │ ├── SpellAction │ │ │ │ ├── styled.ts │ │ │ │ └── index.tsx │ │ │ ├── BoxOfCard │ │ │ │ ├── constants.ts │ │ │ │ ├── ChargePointBar │ │ │ │ │ ├── styles.ts │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── BoxSprite.tsx │ │ │ │ └── OverchargedAnimation.tsx │ │ │ ├── RecentPlayedCard │ │ │ │ ├── styles.ts │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── Player │ │ │ ├── Hand │ │ │ │ ├── styles.ts │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── styles.ts │ │ ├── Status │ │ │ ├── Actions │ │ │ │ ├── styles.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── EmotionSelector.tsx │ │ │ │ └── LeaveButton.tsx │ │ │ ├── LeaveButton │ │ │ │ └── styles.ts │ │ │ ├── styles.ts │ │ │ ├── Timer │ │ │ │ ├── styles.ts │ │ │ │ └── index.tsx │ │ │ ├── HitPointBar │ │ │ │ ├── styles.ts │ │ │ │ └── index.tsx │ │ │ ├── Spells │ │ │ │ ├── styles.ts │ │ │ │ ├── SpellIndicator.tsx │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── Opponents │ │ │ ├── styles.ts │ │ │ ├── Opponent.tsx │ │ │ └── index.tsx │ │ ├── WaitingForOthersDialog.tsx │ │ ├── index.tsx │ │ ├── GameOverDialog.tsx │ │ └── SpellAnimation.tsx │ ├── Initiator │ │ ├── styles.ts │ │ ├── WhatsNew.tsx │ │ └── index.tsx │ ├── Hub │ │ ├── Header │ │ │ ├── Tutorial │ │ │ │ ├── SpellDictionary │ │ │ │ │ ├── styles.ts │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── GameplayTutorial.tsx │ │ │ ├── styles.ts │ │ │ ├── Settings │ │ │ │ ├── styles.ts │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── About.tsx │ │ ├── Room │ │ │ ├── styles.ts │ │ │ ├── Members │ │ │ │ ├── styles.ts │ │ │ │ ├── Member.tsx │ │ │ │ └── index.tsx │ │ │ ├── LeaveRoomButton.tsx │ │ │ └── index.tsx │ │ ├── FindingGame.tsx │ │ ├── Menu.tsx │ │ ├── PlayerNameInput.tsx │ │ ├── GameConfirmDialog.tsx │ │ └── index.tsx │ ├── Notifications │ │ ├── styles.ts │ │ └── index.tsx │ ├── ReconnectDialog.tsx │ └── App.tsx ├── hooks │ ├── index.ts │ ├── useListenServerEvent.ts │ ├── useRevealAnimation.ts │ ├── useShowDialog.ts │ ├── useOnClickOutside.ts │ ├── useInTurn.ts │ ├── useOnEliminate.ts │ ├── useMediaQuery.ts │ ├── useLocalStorage.ts │ └── useNotify.ts ├── index.tsx ├── services │ └── socket.ts ├── index.html ├── types.ts ├── index.css └── HOCs │ └── withSpriteLoading.tsx ├── .prettierrc.json ├── server ├── utilities │ ├── index.ts │ └── waitFor.ts ├── index.ts └── entities │ ├── Client │ ├── State │ │ ├── index.ts │ │ ├── InRoomState.ts │ │ ├── FindingState.ts │ │ ├── InTurnState.ts │ │ ├── ReadyCheckState.ts │ │ ├── InGameState.ts │ │ ├── RoomOwnerState.ts │ │ └── IdleState.ts │ ├── ClientDerived.ts │ └── index.ts │ ├── Game │ ├── GameLoadingChecker.ts │ ├── Deck.ts │ └── index.ts │ ├── Card.ts │ ├── Spell │ ├── PunchSpell.ts │ ├── HealSpell.ts │ ├── PoisonSpell.ts │ ├── PassiveSpell.ts │ ├── ShieldSpell.ts │ ├── MirrorSpell.ts │ ├── SpellFactory.ts │ └── index.ts │ ├── ReadyChecker │ ├── MatchingChecker.ts │ └── index.ts │ ├── Server │ ├── GameMatcher.ts │ └── index.ts │ ├── Player │ ├── SpellManager.ts │ └── index.ts │ └── Room.ts ├── .gitignore ├── shared ├── utils │ ├── index.ts │ ├── number.ts │ └── generateUniqueId.ts ├── constants │ ├── index.ts │ ├── emotion.ts │ ├── spell.ts │ └── event.ts ├── config.ts └── @types.ts ├── node.tsconfig.json ├── .babelrc ├── .eslintrc.json ├── package.json └── tsconfig.json /client/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./color"; 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /client/@extensions/png.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png"; 2 | -------------------------------------------------------------------------------- /client/@extensions/sound.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.mp3"; 2 | -------------------------------------------------------------------------------- /server/utilities/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./waitFor"; 2 | -------------------------------------------------------------------------------- /client/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./COLOR"; 2 | export * from "./ROUTE"; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .cache/ 3 | dist/ 4 | .env 5 | Test/ 6 | .parcel-cache/ -------------------------------------------------------------------------------- /client/styles/animations.ts: -------------------------------------------------------------------------------- 1 | export const fadeOut = [{ opacity: 0 }, { maxWidth: "0px" }]; 2 | -------------------------------------------------------------------------------- /shared/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./generateUniqueId"; 2 | export * from "./number"; 3 | -------------------------------------------------------------------------------- /client/components/Charger/types.ts: -------------------------------------------------------------------------------- 1 | export type ChargePointBarState = "Safe" | "Warning" | "Danger"; 2 | -------------------------------------------------------------------------------- /client/constants/ROUTE.ts: -------------------------------------------------------------------------------- 1 | export enum ROUTE { 2 | Init, 3 | Hub, 4 | InGame, 5 | Test, 6 | } 7 | -------------------------------------------------------------------------------- /client/atoms/version.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export const versionAtom = atom("v0.2.2"); 4 | -------------------------------------------------------------------------------- /shared/utils/number.ts: -------------------------------------------------------------------------------- 1 | export const getNumberSign = (power: number): "+" | "-" => (power >= 0 ? "+" : "-"); 2 | -------------------------------------------------------------------------------- /client/assets/sounds/music.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhanDungTri/weird-box/HEAD/client/assets/sounds/music.mp3 -------------------------------------------------------------------------------- /client/assets/sprites/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhanDungTri/weird-box/HEAD/client/assets/sprites/logo.png -------------------------------------------------------------------------------- /client/languages/index.ts: -------------------------------------------------------------------------------- 1 | import en from "./en"; 2 | import vi from "./vi"; 3 | 4 | export default { en, vi }; 5 | -------------------------------------------------------------------------------- /shared/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./spell"; 2 | export * from "./event"; 3 | export * from "./emotion"; 4 | -------------------------------------------------------------------------------- /client/assets/sounds/effects.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhanDungTri/weird-box/HEAD/client/assets/sounds/effects.mp3 -------------------------------------------------------------------------------- /client/assets/sprites/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhanDungTri/weird-box/HEAD/client/assets/sprites/icons.png -------------------------------------------------------------------------------- /shared/utils/generateUniqueId.ts: -------------------------------------------------------------------------------- 1 | export const generateUniqueId = (): string => Math.random().toString(36).substr(2, 9); 2 | -------------------------------------------------------------------------------- /client/assets/fonts/dogicapixel.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhanDungTri/weird-box/HEAD/client/assets/fonts/dogicapixel.ttf -------------------------------------------------------------------------------- /client/assets/sprites/emotions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhanDungTri/weird-box/HEAD/client/assets/sprites/emotions.png -------------------------------------------------------------------------------- /client/components/Carousel/types.ts: -------------------------------------------------------------------------------- 1 | export type CarouselContentProps = { 2 | items: number; 3 | current: number; 4 | }; 5 | -------------------------------------------------------------------------------- /client/assets/fonts/VT323-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhanDungTri/weird-box/HEAD/client/assets/fonts/VT323-Regular.ttf -------------------------------------------------------------------------------- /client/assets/sprites/box_of_cards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhanDungTri/weird-box/HEAD/client/assets/sprites/box_of_cards.png -------------------------------------------------------------------------------- /node.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /server/utilities/waitFor.ts: -------------------------------------------------------------------------------- 1 | export const waitFor = (ms: number): Promise => new Promise((res) => setTimeout(res, ms)); 2 | -------------------------------------------------------------------------------- /client/assets/sprites/loading_animation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhanDungTri/weird-box/HEAD/client/assets/sprites/loading_animation.png -------------------------------------------------------------------------------- /client/assets/sprites/spell_animations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhanDungTri/weird-box/HEAD/client/assets/sprites/spell_animations.png -------------------------------------------------------------------------------- /client/assets/sprites/card_content_frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhanDungTri/weird-box/HEAD/client/assets/sprites/card_content_frame.png -------------------------------------------------------------------------------- /shared/constants/emotion.ts: -------------------------------------------------------------------------------- 1 | export enum EMOTION { 2 | Bonk = "bonk", 3 | Nervous = "nervous", 4 | Laugh = "laugh", 5 | Angry = "angry", 6 | } 7 | -------------------------------------------------------------------------------- /client/atoms/route.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { ROUTE } from "../constants"; 3 | 4 | export const routeAtom = atom(ROUTE.Init); 5 | -------------------------------------------------------------------------------- /client/atoms/room.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { RoomInfo } from "../../shared/@types"; 3 | 4 | export const roomAtom = atom(null); 5 | -------------------------------------------------------------------------------- /client/styles/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./modifyStyles"; 2 | export * from "./pixelBorderStyle"; 3 | export * from "./slotStyle"; 4 | export * from "./animations"; 5 | -------------------------------------------------------------------------------- /client/components/Carousel/CarouselItem.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const CarouselItem = styled.div` 4 | height: 100%; 5 | padding: 0px 24px; 6 | `; 7 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const StyledGameBoard = styled.div` 4 | position: relative; 5 | flex-grow: 1; 6 | `; 7 | -------------------------------------------------------------------------------- /shared/config.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_MAX_HP = 100; 2 | export const DEFAULT_TIME_PER_TURN = 20000; 3 | export const CONFIRM_WAIT_TIME = 15000; 4 | export const MAX_PLAYERS_PER_GAME = 4; 5 | -------------------------------------------------------------------------------- /client/views/Initiator/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const LoadingProgress = styled.div` 4 | width: 100%; 5 | position: fixed; 6 | bottom: 12px; 7 | `; 8 | -------------------------------------------------------------------------------- /client/atoms/sound.ts: -------------------------------------------------------------------------------- 1 | import { Howl } from "howler"; 2 | import { atom } from "jotai"; 3 | 4 | export const soundAtom = atom(null); 5 | export const musicAtom = atom(null); 6 | -------------------------------------------------------------------------------- /client/components/Loading/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const StyledLoading = styled.div` 4 | display: flex; 5 | gap: 4px; 6 | align-items: flex-end; 7 | `; 8 | -------------------------------------------------------------------------------- /client/components/Grid.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | const Grid = styled.div` 4 | box-sizing: border-box; 5 | display: grid; 6 | gap: 4px; 7 | `; 8 | 9 | export default Grid; 10 | -------------------------------------------------------------------------------- /client/views/Game/Player/Hand/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const StyledHand = styled.div` 4 | display: flex; 5 | margin: 8px; 6 | gap: 4px; 7 | justify-content: center; 8 | `; 9 | -------------------------------------------------------------------------------- /client/views/Game/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import Page from "../../components/Page"; 3 | 4 | export const StyledGame = styled(Page)` 5 | display: flex; 6 | flex-direction: column; 7 | `; 8 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import Server from "./entities/Server"; 2 | import { config as dotenvConfig } from "dotenv"; 3 | 4 | dotenvConfig(); 5 | Server.port = parseInt(process.env.PORT || "3000"); 6 | Server.getInstance(); 7 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/DeckCounter/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const StyledDeckCounter = styled.div` 4 | position: absolute; 5 | bottom: 0; 6 | right: 0; 7 | margin: 4px; 8 | `; 9 | -------------------------------------------------------------------------------- /client/atoms/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./notification"; 2 | export * from "./route"; 3 | export * from "./room"; 4 | export * from "./sound"; 5 | export * from "./audioSettings"; 6 | export * from "./language"; 7 | export * from "./version"; 8 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/SpellAction/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const StyledSpellAction = styled.div` 4 | position: absolute; 5 | top: 0; 6 | left: 0; 7 | transition: opacity 0.4s; 8 | `; 9 | -------------------------------------------------------------------------------- /client/atoms/language.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import language from "../languages"; 3 | 4 | export const chosenLanguageAtom = atom<"en" | "vi">("en"); 5 | export const languageAtom = atom((get) => language[get(chosenLanguageAtom)]); 6 | -------------------------------------------------------------------------------- /client/views/Game/Status/Actions/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { clickableIconStyle } from "../../../../styles"; 3 | 4 | export const iconButtonStyle = css` 5 | ${clickableIconStyle}; 6 | margin: 4px 0px; 7 | `; 8 | -------------------------------------------------------------------------------- /client/views/Game/Status/LeaveButton/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { clickableIconStyle } from "../../../../styles"; 3 | 4 | export const leaveButtonStyle = css` 5 | ${clickableIconStyle}; 6 | margin: 4px 0px; 7 | `; 8 | -------------------------------------------------------------------------------- /client/atoms/audioSettings.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | type AudioSettings = { 4 | music: boolean; 5 | sound: boolean; 6 | }; 7 | 8 | export const audioSettingsAtom = atom({ 9 | music: true, 10 | sound: true, 11 | }); 12 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | ["@babel/preset-react", { "runtime": "automatic", "importSource": "@emotion/react" }] 5 | ], 6 | "plugins": ["transform-inline-environment-variables", "@emotion/babel-plugin"] 7 | } 8 | -------------------------------------------------------------------------------- /client/components/H1.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { COLOR } from "../constants"; 3 | 4 | const H1 = styled.div` 5 | font-size: 14px; 6 | padding: 8px; 7 | font-weight: bold; 8 | color: ${COLOR.Black}; 9 | `; 10 | 11 | export default H1; 12 | -------------------------------------------------------------------------------- /client/constants/COLOR.ts: -------------------------------------------------------------------------------- 1 | export enum COLOR { 2 | White = "#ffffff", 3 | Black = "#000000", 4 | Primary = "#f2eecb", 5 | Normal = "#2f2f2f", 6 | Danger = "#ff4412", 7 | Warning = "#ffd04c", 8 | Safe = "#99e550", 9 | Info = "#1ca27f", 10 | Disabled = "dddddd", 11 | } 12 | -------------------------------------------------------------------------------- /client/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useInTurn"; 2 | export * from "./useListenServerEvent"; 3 | export * from "./useOnClickOutside"; 4 | export * from "./useOnEliminate"; 5 | export * from "./useRevealAnimation"; 6 | export * from "./useShowDialog"; 7 | export * from "./useLocalStorage"; 8 | -------------------------------------------------------------------------------- /client/components/CenterizedGrid.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import Grid from "./Grid"; 3 | 4 | const CenterizedGrid = styled(Grid)` 5 | justify-content: center; 6 | justify-items: center; 7 | align-content: center; 8 | `; 9 | 10 | export default CenterizedGrid; 11 | -------------------------------------------------------------------------------- /client/views/Hub/Header/Tutorial/SpellDictionary/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import Grid from "../../../../../components/Grid"; 3 | 4 | export const SpellDescription = styled(Grid)` 5 | grid-template-columns: 48px auto; 6 | align-items: center; 7 | gap: 8px; 8 | `; 9 | -------------------------------------------------------------------------------- /client/views/Hub/Room/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import Grid from "../../../components/Grid"; 3 | import { xCenterStyle } from "../../../styles"; 4 | 5 | export const StyledRoom = styled(Grid)` 6 | position: absolute; 7 | ${xCenterStyle} 8 | bottom: 14px; 9 | `; 10 | -------------------------------------------------------------------------------- /client/atoms/notification.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { StyleVariation } from "../../shared/@types"; 3 | 4 | type Notification = { 5 | id: string; 6 | message: string; 7 | variation: StyleVariation; 8 | }; 9 | 10 | export const notificationsAtom = atom([]); 11 | -------------------------------------------------------------------------------- /client/styles/slotStyle.ts: -------------------------------------------------------------------------------- 1 | import { css, SerializedStyles } from "@emotion/react"; 2 | import { pixelBorderStyle } from "."; 3 | import { COLOR } from "../constants"; 4 | 5 | export const slotStyle = (scale = 1): SerializedStyles => css` 6 | ${pixelBorderStyle(2 * scale, [COLOR.Normal])}; 7 | position: relative; 8 | `; 9 | -------------------------------------------------------------------------------- /client/views/Game/Status/Actions/index.tsx: -------------------------------------------------------------------------------- 1 | import EmotionSelector from "./EmotionSelector"; 2 | import LeaveButton from "./LeaveButton"; 3 | 4 | const Actions = (): JSX.Element => { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default Actions; 14 | -------------------------------------------------------------------------------- /client/hooks/useListenServerEvent.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import socket from "../services/socket"; 3 | 4 | export const useListenServerEvent = (...[ev, listener]: Parameters): void => { 5 | useEffect(() => { 6 | socket.on(ev, listener); 7 | return () => void socket.off(ev, listener); 8 | }, []); 9 | }; 10 | -------------------------------------------------------------------------------- /client/views/Hub/Header/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import Grid from "../../../components/Grid"; 3 | 4 | export const StyledHeader = styled(Grid)` 5 | box-sizing: border-box; 6 | grid-template-columns: auto auto; 7 | position: fixed; 8 | top: 0; 9 | padding: 8px; 10 | width: 100%; 11 | justify-content: space-between; 12 | `; 13 | -------------------------------------------------------------------------------- /shared/constants/spell.ts: -------------------------------------------------------------------------------- 1 | export enum SPELL_NAME { 2 | Punch = "punch", 3 | Poison = "poison", 4 | Heal = "heal", 5 | Void = "void", 6 | Shield = "shield", 7 | Mirror = "mirror", 8 | } 9 | 10 | export enum PASSIVE_ACTION { 11 | Block = "block", 12 | ShieldPierce = "shield pierce", 13 | Reflect = "reflect", 14 | MirrorPierce = "mirror pierce", 15 | } 16 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/BoxOfCard/constants.ts: -------------------------------------------------------------------------------- 1 | import BoxOfCardsSprites from "url:../../../../assets/sprites/box_of_cards.png"; 2 | 3 | export const BOX_OF_CARD_SIZE = 59; 4 | export const ANIMATION_STEP = 11; 5 | 6 | export const COMMON_PROPS = { 7 | src: BoxOfCardsSprites, 8 | size: [BOX_OF_CARD_SIZE, BOX_OF_CARD_SIZE] as [number, number], 9 | scale: 3, 10 | }; 11 | -------------------------------------------------------------------------------- /client/index.tsx: -------------------------------------------------------------------------------- 1 | import { enableES5 } from "immer"; 2 | import "normalize.css"; 3 | import ReactDOM from "react-dom"; 4 | import "./index.css"; 5 | import App from "./views/App"; 6 | import Notifications from "./views/Notifications"; 7 | 8 | enableES5(); 9 | 10 | ReactDOM.render( 11 | <> 12 | 13 | 14 | , 15 | document.getElementById("app") 16 | ); 17 | -------------------------------------------------------------------------------- /client/views/Hub/Header/Settings/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | import Grid from "../../../../components/Grid"; 4 | 5 | export const toggleStyle = css` 6 | filter: opacity(20%); 7 | `; 8 | 9 | export const PartialSettings = styled(Grid)` 10 | gap: 8px; 11 | grid-template-columns: 3fr 1fr 1fr; 12 | align-items: center; 13 | `; 14 | -------------------------------------------------------------------------------- /server/entities/Client/State/index.ts: -------------------------------------------------------------------------------- 1 | import Client from ".."; 2 | import ClientDerived from "../ClientDerived"; 3 | 4 | abstract class ClientState extends ClientDerived { 5 | constructor(client: Client) { 6 | super(client); 7 | } 8 | 9 | public exit(): void { 10 | return; 11 | } 12 | 13 | public enter(): void { 14 | return; 15 | } 16 | } 17 | 18 | export default ClientState; 19 | -------------------------------------------------------------------------------- /client/services/socket.ts: -------------------------------------------------------------------------------- 1 | import { Socket, io } from "socket.io-client"; 2 | import { EventsFromClient, EventsFromServer } from "../../shared/@types"; 3 | 4 | const socket: Socket = io( 5 | process.env.NODE_ENV === "development" ? `http://localhost:3000/` : "/", 6 | { 7 | reconnection: false, 8 | autoConnect: false, 9 | } 10 | ); 11 | 12 | export default socket; 13 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Weird Box 7 | 8 | 9 |
10 |
11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /server/entities/Client/ClientDerived.ts: -------------------------------------------------------------------------------- 1 | import { ClientSocket } from "../../../shared/@types"; 2 | import Client from "."; 3 | 4 | abstract class ClientDerived { 5 | public readonly socket: ClientSocket; 6 | public readonly id: string; 7 | 8 | constructor(protected client: Client) { 9 | this.socket = client.getSocket(); 10 | this.id = client.getId(); 11 | } 12 | } 13 | 14 | export default ClientDerived; 15 | -------------------------------------------------------------------------------- /client/views/Game/Status/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | import Grid from "../../../components/Grid"; 4 | 5 | export const StyledStatus = styled(Grid)` 6 | box-sizing: border-box; 7 | grid-template-rows: 32px 32px 16px; 8 | `; 9 | 10 | export const horizontalStatusStyle = css` 11 | margin: 8px; 12 | grid-template-rows: 32px 16px; 13 | grid-template-columns: 1fr 2fr 24px 24px; 14 | `; 15 | -------------------------------------------------------------------------------- /client/hooks/useRevealAnimation.ts: -------------------------------------------------------------------------------- 1 | import { ForwardedProps, useTransition, UseTransitionResult } from "react-spring"; 2 | 3 | export const useRevealAnimation = ( 4 | show: boolean 5 | ): UseTransitionResult>[] => { 6 | const transitions = useTransition(show, null, { 7 | from: { position: "absolute", opacity: 0 }, 8 | enter: { opacity: 1 }, 9 | leave: { opacity: 0 }, 10 | }); 11 | 12 | return transitions; 13 | }; 14 | -------------------------------------------------------------------------------- /client/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react-markdown"; 2 | 3 | export type SpriteProps = { 4 | size: [number, number]; 5 | src: string; 6 | className?: string; 7 | steps?: number; 8 | fps?: number; 9 | row?: number; 10 | scale?: number; 11 | loop?: number; 12 | stop?: boolean; 13 | children?: ReactNode; 14 | onAnimationEnd?: () => void; 15 | onTransitionEnd?: () => void; 16 | onClick?: () => void; 17 | onReachFrame?: (frame: number) => void; 18 | }; 19 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/RecentPlayedCard/styles.ts: -------------------------------------------------------------------------------- 1 | import { css, keyframes } from "@emotion/react"; 2 | 3 | export const cardPlayedKeyframes = keyframes` 4 | \ 0% { 5 | top: 100%; 6 | opacity: 0; 7 | } 8 | 9 | \ 50% { 10 | top: 90%; 11 | opacity: 1; 12 | } 13 | 14 | \ 100% { 15 | top: 80%; 16 | opacity: 0; 17 | } 18 | `; 19 | 20 | export const recentPlayedCard = css` 21 | position: absolute; 22 | bottom: 0; 23 | left: 0; 24 | margin: 6px 12px; 25 | `; 26 | -------------------------------------------------------------------------------- /client/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { COLOR } from "../constants"; 3 | import { centerizeStyle, pixelBorderStyle } from "../styles"; 4 | 5 | const Page = styled.div` 6 | ${centerizeStyle} 7 | width: 100%; 8 | height: 100%; 9 | max-width: 600px; 10 | max-height: 800px; 11 | overflow: hidden; 12 | 13 | @media screen and (min-width: 600px) { 14 | ${pixelBorderStyle(4, [COLOR.Normal])}; 15 | } 16 | `; 17 | 18 | export default Page; 19 | -------------------------------------------------------------------------------- /client/hooks/useShowDialog.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | type ShowDialogHook = [ 4 | boolean, 5 | { 6 | hide: () => void; 7 | reveal: () => void; 8 | } 9 | ]; 10 | 11 | const useShowDialog = (init = false): ShowDialogHook => { 12 | const [shouldShow, show] = useState(init); 13 | 14 | const hide = () => show(false); 15 | const reveal = () => show(true); 16 | 17 | return [shouldShow, { reveal, hide }]; 18 | }; 19 | 20 | export default useShowDialog; 21 | -------------------------------------------------------------------------------- /server/entities/Game/GameLoadingChecker.ts: -------------------------------------------------------------------------------- 1 | import Client from "../Client"; 2 | import Game from "."; 3 | import ReadyChecker from "../ReadyChecker"; 4 | import Room from "../Room"; 5 | 6 | class GameLoadingChecker extends ReadyChecker { 7 | constructor(private game: Game, clients: Client[], room?: Room) { 8 | super(clients, room); 9 | } 10 | 11 | protected onQualify(): void { 12 | this.game.start(this.clients); 13 | } 14 | } 15 | 16 | export default GameLoadingChecker; 17 | -------------------------------------------------------------------------------- /server/entities/Card.ts: -------------------------------------------------------------------------------- 1 | import { SPELL_NAME } from "../../shared/constants"; 2 | import { generateUniqueId } from "../../shared/utils"; 3 | 4 | class Card { 5 | public readonly id = generateUniqueId(); 6 | 7 | constructor(private power: number, private spell: SPELL_NAME = SPELL_NAME.Void) {} 8 | 9 | public getPower(): number { 10 | return this.power; 11 | } 12 | 13 | public getSpell(): SPELL_NAME { 14 | return this.spell; 15 | } 16 | } 17 | 18 | export default Card; 19 | -------------------------------------------------------------------------------- /client/components/EmptySlot.tsx: -------------------------------------------------------------------------------- 1 | import { centerizeStyle, slotStyle } from "../styles"; 2 | import Icon from "./Icon"; 3 | 4 | type EmptySlotProps = { 5 | scale?: number; 6 | className?: string; 7 | }; 8 | 9 | const EmptySlot = ({ scale = 1, className }: EmptySlotProps): JSX.Element => { 10 | return ( 11 |
12 | 13 |
14 | ); 15 | }; 16 | 17 | export default EmptySlot; 18 | -------------------------------------------------------------------------------- /client/styles/modifyStyles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | 3 | export const centerizeStyle = css` 4 | position: absolute; 5 | top: 50%; 6 | left: 50%; 7 | transform: translate(-50%, -50%); 8 | `; 9 | 10 | export const xCenterStyle = css` 11 | left: 50%; 12 | transform: translateX(-50%); 13 | `; 14 | 15 | export const disabledStyle = css` 16 | filter: grayscale(100%); 17 | `; 18 | 19 | export const clickableIconStyle = css` 20 | position: relative; 21 | cursor: pointer; 22 | `; 23 | -------------------------------------------------------------------------------- /client/views/Game/Status/Timer/styles.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | import { COLOR } from "../../../../constants"; 4 | 5 | export const countdownKeyframes = keyframes` 6 | from { 7 | width: calc(100% - 8px); 8 | background-color: ${COLOR.Safe}; 9 | } 10 | 11 | to { 12 | width: 0%; 13 | background-color: ${COLOR.Danger}; 14 | } 15 | `; 16 | 17 | export const StyledTimer = styled.div` 18 | margin: 4px; 19 | height: 8px; 20 | `; 21 | -------------------------------------------------------------------------------- /server/entities/Spell/PunchSpell.ts: -------------------------------------------------------------------------------- 1 | import { SPELL_NAME } from "../../../shared/constants"; 2 | import Player from "../Player"; 3 | import Spell from "."; 4 | 5 | class PunchSpell extends Spell { 6 | constructor(chargePoint: number, target: Player, caster: Player) { 7 | super(SPELL_NAME.Punch, target, caster); 8 | this.strength = chargePoint; 9 | } 10 | 11 | public async trigger(): Promise { 12 | this.target.changeHitPoint(-this.strength); 13 | } 14 | } 15 | 16 | export default PunchSpell; 17 | -------------------------------------------------------------------------------- /server/entities/Spell/HealSpell.ts: -------------------------------------------------------------------------------- 1 | import Player from "../Player"; 2 | import Spell from "."; 3 | import { SPELL_NAME } from "../../../shared/constants"; 4 | 5 | class HealSpell extends Spell { 6 | constructor(chargePoint: number, caster: Player) { 7 | super(SPELL_NAME.Heal, caster, caster); 8 | this.strength = chargePoint; 9 | } 10 | 11 | public async trigger(): Promise { 12 | this.target.changeHitPoint(this.strength); 13 | this.target.purify(); 14 | } 15 | } 16 | 17 | export default HealSpell; 18 | -------------------------------------------------------------------------------- /client/views/Game/Player/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import socket from "../../../services/socket"; 3 | import SpellAnimation from "../SpellAnimation"; 4 | import Status from "../Status"; 5 | import Hand from "./Hand"; 6 | 7 | const Player = (): JSX.Element => { 8 | return ( 9 | <> 10 |
11 | 12 | 13 |
14 | 15 | 16 | ); 17 | }; 18 | 19 | export default memo(Player); 20 | -------------------------------------------------------------------------------- /client/components/Sprite/styles.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | 4 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 5 | export const spriteAnimation = (sheetWidth: number) => keyframes` 6 | from { 7 | background-position-x: 0px; 8 | } 9 | to { 10 | background-position-x: -${sheetWidth}px; 11 | } 12 | `; 13 | 14 | export const StyledSprite = styled.div` 15 | position: absolute; 16 | display: inline-block; 17 | overflow: hidden; 18 | `; 19 | -------------------------------------------------------------------------------- /server/entities/Spell/PoisonSpell.ts: -------------------------------------------------------------------------------- 1 | import Spell from "."; 2 | import { SPELL_NAME } from "../../../shared/constants"; 3 | import Player from "../Player"; 4 | 5 | class PoisonSpell extends Spell { 6 | constructor(chargePoint: number, target: Player, caster: Player) { 7 | super(SPELL_NAME.Poison, target, caster, 2); 8 | this.strength = chargePoint; 9 | } 10 | 11 | public async trigger(): Promise { 12 | this.target.changeHitPoint(-this.strength); 13 | this.duration--; 14 | } 15 | } 16 | 17 | export default PoisonSpell; 18 | -------------------------------------------------------------------------------- /client/views/Hub/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { versionAtom } from "../../../atoms"; 3 | import About from "./About"; 4 | import Settings from "./Settings"; 5 | import { StyledHeader } from "./styles"; 6 | import Tutorial from "./Tutorial"; 7 | 8 | const Header = (): JSX.Element => { 9 | const [version] = useAtom(versionAtom); 10 | 11 | return ( 12 | 13 |
{version}
14 |
15 | 16 | 17 | 18 |
19 |
20 | ); 21 | }; 22 | 23 | export default Header; 24 | -------------------------------------------------------------------------------- /client/views/Game/Opponents/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import Grid from "../../../components/Grid"; 3 | import H1 from "../../../components/H1"; 4 | import { slotStyle } from "../../../styles"; 5 | 6 | export const StyledOpponents = styled(Grid)` 7 | margin: 4px; 8 | height: 120px; 9 | grid-template-columns: repeat(3, 1fr); 10 | `; 11 | 12 | export const StyledOpponent = styled(Grid)` 13 | ${slotStyle(2)}; 14 | grid-template-rows: auto 24px; 15 | `; 16 | 17 | export const OpponentName = styled(H1)` 18 | background-color: #be6e46; 19 | text-align: center; 20 | padding: 4px; 21 | `; 22 | -------------------------------------------------------------------------------- /client/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Dogica Pixel"; 3 | src: url("./assets/fonts/dogicapixel.ttf"); 4 | } 5 | 6 | @font-face { 7 | font-family: "Main Font"; 8 | src: url("./assets/fonts/VT323-Regular.ttf"); 9 | } 10 | 11 | html { 12 | font-size: 14px; 13 | font-family: "Main Font"; 14 | color: black; 15 | image-rendering: optimizeSpeed; 16 | image-rendering: pixelated; 17 | } 18 | 19 | #notification { 20 | position: fixed; 21 | bottom: 0; 22 | width: 100vw; 23 | } 24 | 25 | button, 26 | input { 27 | border: none; 28 | outline: none; 29 | cursor: pointer; 30 | } 31 | -------------------------------------------------------------------------------- /server/entities/ReadyChecker/MatchingChecker.ts: -------------------------------------------------------------------------------- 1 | import { SERVER_EVENT_NAME } from "../../../shared/constants"; 2 | import Client from "../Client"; 3 | import Game from "../Game"; 4 | import ReadyChecker from "."; 5 | import Room from "../Room"; 6 | 7 | class MatchingChecker extends ReadyChecker { 8 | constructor(clients: Client[], room?: Room) { 9 | super(clients, room); 10 | clients.forEach((c) => c.getSocket().emit(SERVER_EVENT_NAME.UpdateGameMatchingStatus, "Found")); 11 | } 12 | 13 | protected onQualify(): void { 14 | new Game(this.clients, this.room); 15 | } 16 | } 17 | 18 | export default MatchingChecker; 19 | -------------------------------------------------------------------------------- /client/hooks/useOnClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useRef } from "react"; 2 | 3 | export const useOnClickOutside = (onClickOutside: () => void): RefObject => { 4 | const ref = useRef(null); 5 | 6 | useEffect(() => { 7 | const handleClickOutside = (event: MouseEvent) => { 8 | if (ref.current && !ref.current.contains(event.target as Node)) onClickOutside(); 9 | }; 10 | 11 | document.addEventListener("click", handleClickOutside, true); 12 | 13 | return () => document.removeEventListener("click", handleClickOutside, true); 14 | }, []); 15 | 16 | return ref; 17 | }; 18 | -------------------------------------------------------------------------------- /client/views/Game/Status/HitPointBar/styles.ts: -------------------------------------------------------------------------------- 1 | import { css, keyframes } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | import { centerizeStyle } from "../../../../styles"; 4 | 5 | const hpDiffKeyframes = (goUp = false) => keyframes` 6 | to { 7 | top: ${goUp ? -100 : 500}%; 8 | } 9 | `; 10 | 11 | export const HPDiff = styled.div<{ goUp?: boolean }>` 12 | ${centerizeStyle}; 13 | top: ${({ goUp = false }) => (goUp ? 0 : 400)}%; 14 | font-size: 14px; 15 | font-weight: bold; 16 | z-index: 2; 17 | animation: ${({ goUp = false }) => 18 | css` 19 | ${hpDiffKeyframes(goUp)} 0.8s forwards 20 | `}; 21 | `; 22 | -------------------------------------------------------------------------------- /client/hooks/useInTurn.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { SERVER_EVENT_NAME } from "../../shared/constants"; 3 | import socket from "../services/socket"; 4 | import { useListenServerEvent } from "./useListenServerEvent"; 5 | 6 | export const useInTurn = (id: string): boolean => { 7 | const [isInturn, inTurn] = useState(false); 8 | 9 | useListenServerEvent(SERVER_EVENT_NAME.NewTurn, (target: string) => inTurn(target === id)); 10 | useListenServerEvent(SERVER_EVENT_NAME.CardPlayed, () => inTurn(false)); 11 | 12 | useEffect(() => { 13 | socket.once(SERVER_EVENT_NAME.GameOver, () => inTurn(false)); 14 | }, []); 15 | 16 | return isInturn; 17 | }; 18 | -------------------------------------------------------------------------------- /client/hooks/useOnEliminate.ts: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { useEffect, useState } from "react"; 3 | import { SERVER_EVENT_NAME } from "../../shared/constants"; 4 | import { soundAtom } from "../atoms"; 5 | import { useListenServerEvent } from "./useListenServerEvent"; 6 | 7 | export const useOnEliminate = (id: string): boolean => { 8 | const [isEliminated, eliminate] = useState(false); 9 | const [sound] = useAtom(soundAtom); 10 | 11 | useEffect(() => { 12 | if (isEliminated) sound?.play("Eliminated"); 13 | }, [isEliminated]); 14 | 15 | useListenServerEvent(SERVER_EVENT_NAME.PlayerEliminated, (target: string) => eliminate(target === id)); 16 | return isEliminated; 17 | }; 18 | -------------------------------------------------------------------------------- /client/views/Hub/Room/Members/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | import Grid from "../../../../components/Grid"; 4 | import { slotStyle } from "../../../../styles"; 5 | 6 | export const StyledMembers = styled(Grid)` 7 | grid-template-columns: repeat(4, 75px); 8 | `; 9 | 10 | export const StyledMember = styled(Grid)` 11 | ${slotStyle()} 12 | justify-items: center; 13 | padding: 4px; 14 | `; 15 | 16 | export const MemberName = styled.div` 17 | font-size: 12px; 18 | text-align: center; 19 | word-break: break-all; 20 | `; 21 | 22 | export const keyStyle = css` 23 | background-color: white; 24 | top: -14px; 25 | right: -6px; 26 | `; 27 | -------------------------------------------------------------------------------- /client/hooks/useMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | // source: https://www.30secondsofcode.org/react/s/use-media-query 4 | const useMediaQuery = (query: string): boolean => { 5 | if (typeof window === "undefined" || typeof window.matchMedia === "undefined") return false; 6 | 7 | const mediaQuery = window.matchMedia(query); 8 | const [match, setMatch] = useState(!!mediaQuery.matches); 9 | 10 | useEffect(() => { 11 | const handler = () => setMatch(!!mediaQuery.matches); 12 | mediaQuery.addEventListener("change", handler); 13 | return () => mediaQuery.removeEventListener("change", handler); 14 | }, []); 15 | 16 | return match; 17 | }; 18 | 19 | export default useMediaQuery; 20 | -------------------------------------------------------------------------------- /client/components/Carousel/CarouselNavigator/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { COLOR } from "../../../constants"; 3 | import { xCenterStyle } from "../../../styles"; 4 | import Grid from "../../Grid"; 5 | import { CarouselContentProps } from "../types"; 6 | 7 | export const StyledCaurouselNavigator = styled(Grid)>` 8 | ${xCenterStyle}; 9 | position: absolute; 10 | bottom: 8px; 11 | grid-template-columns: repeat(${({ items }) => items}, auto); 12 | `; 13 | 14 | export const CarouselNode = styled.div<{ active?: boolean }>` 15 | width: 0.5rem; 16 | height: 0.5rem; 17 | background-color: ${COLOR.Normal}; 18 | filter: opacity(${({ active = false }) => (active ? 100 : 50)}%); 19 | `; 20 | -------------------------------------------------------------------------------- /client/components/Carousel/CarouselNavigator/index.tsx: -------------------------------------------------------------------------------- 1 | import { CarouselContentProps } from "../types"; 2 | import { StyledCaurouselNavigator, CarouselNode } from "./styles"; 3 | 4 | type CarouselNavigatorProps = CarouselContentProps & { 5 | onClick: (index: number) => void; 6 | }; 7 | 8 | const CarouselNavigator = ({ items, current, onClick }: CarouselNavigatorProps): JSX.Element => { 9 | const drawNode = () => { 10 | const arr: JSX.Element[] = []; 11 | for (let i = 0; i < items; i++) 12 | arr.push( onClick(i)} />); 13 | return arr; 14 | }; 15 | 16 | return {drawNode()}; 17 | }; 18 | 19 | export default CarouselNavigator; 20 | -------------------------------------------------------------------------------- /client/components/Dropdown/styles.ts: -------------------------------------------------------------------------------- 1 | import { css, SerializedStyles } from "@emotion/react"; 2 | import { COLOR } from "../../constants"; 3 | import { pixelBorderStyle } from "../../styles"; 4 | 5 | export const dropDownIconStyle = css` 6 | position: relative; 7 | margin-left: 10px; 8 | `; 9 | 10 | export const dropDownContentStyle = (top: boolean): SerializedStyles => css` 11 | ${pixelBorderStyle(2, [COLOR.Normal])} 12 | padding: 4px; 13 | display: grid; 14 | gap: 2px; 15 | position: absolute; 16 | grid-area: ${top ? "top" : "bottom"}; 17 | background-color: ${COLOR.White}; 18 | ${top 19 | ? css` 20 | top: 0; 21 | transform: translateY(calc(-100% - 8px)); 22 | ` 23 | : css` 24 | margin-top: 8px; 25 | `}; 26 | `; 27 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "prettier"], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaFeatures": { 11 | "jsx": true 12 | }, 13 | "ecmaVersion": 12, 14 | "sourceType": "module" 15 | }, 16 | "settings": { 17 | "react": { "version": "detect" } 18 | }, 19 | "plugins": ["react", "@typescript-eslint", "prettier"], 20 | "rules": { 21 | "prettier/prettier": "error", 22 | "react/jsx-uses-react": "off", 23 | "react/react-in-jsx-scope": "off", 24 | "@typescript-eslint/no-explicit-any": "off" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/entities/Client/State/InRoomState.ts: -------------------------------------------------------------------------------- 1 | import ClientState from "."; 2 | import Client from ".."; 3 | import { CLIENT_EVENT_NAME, SERVER_EVENT_NAME } from "../../../../shared/constants"; 4 | import Room from "../../Room"; 5 | 6 | class InRoomState extends ClientState { 7 | constructor(client: Client, protected room: Room) { 8 | super(client); 9 | this.leaveRoom = this.leaveRoom.bind(this); 10 | } 11 | 12 | private leaveRoom() { 13 | this.room.remove(this.client); 14 | } 15 | 16 | public enter(): void { 17 | this.socket.on(CLIENT_EVENT_NAME.LeaveRoom, this.leaveRoom); 18 | this.socket.emit(SERVER_EVENT_NAME.GetRoomInfo, this.room.getInfo()); 19 | } 20 | 21 | public exit(): void { 22 | this.socket.off(CLIENT_EVENT_NAME.LeaveRoom, this.leaveRoom); 23 | } 24 | } 25 | 26 | export default InRoomState; 27 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/DeckCounter/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { SERVER_EVENT_NAME } from "../../../../../shared/constants"; 3 | import Icon from "../../../../components/Icon"; 4 | import { useListenServerEvent } from "../../../../hooks"; 5 | import { centerizeStyle } from "../../../../styles"; 6 | import { StyledDeckCounter } from "./styles"; 7 | 8 | const DeckCounter = (): JSX.Element => { 9 | const [size, setSize] = useState(0); 10 | 11 | useListenServerEvent(SERVER_EVENT_NAME.NewTurn, (_: string, size: number) => setSize(size)); 12 | 13 | return ( 14 | 15 | 16 |
{size}
17 |
18 | ); 19 | }; 20 | 21 | export default DeckCounter; 22 | -------------------------------------------------------------------------------- /client/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 4 | export const useLocalStorage = (key: string, initialValue: string | number) => { 5 | const [storedValue, setStoredValue] = useState(() => { 6 | try { 7 | const item = window.localStorage.getItem(key); 8 | return item ? JSON.parse(item) : initialValue; 9 | } catch (error) { 10 | console.log(error); 11 | return initialValue; 12 | } 13 | }); 14 | 15 | const setValue = (value: string | number) => { 16 | try { 17 | setStoredValue(value); 18 | window.localStorage.setItem(key, JSON.stringify(value)); 19 | } catch (error) { 20 | console.log(error); 21 | } 22 | }; 23 | return [storedValue, setValue] as const; 24 | }; 25 | -------------------------------------------------------------------------------- /client/styles/pixelBorderStyle.ts: -------------------------------------------------------------------------------- 1 | import { css, SerializedStyles } from "@emotion/react"; 2 | 3 | type BorderColors = [string] | [string, string] | [string, string, string, string]; 4 | 5 | export const pixelBorderStyle = (width: number, color: BorderColors, innerColor?: BorderColors): SerializedStyles => { 6 | const shape = [`0px ${width}px`, `${-width}px 0px`, `0px ${-width}px`, `${width}px 0px`]; 7 | const border = shape.map((edge, i) => `${edge} ${color[i] || color[i - 2] || color[0]}`).join(", "); 8 | const innerBorder = innerColor 9 | ? shape.map((edge, i) => `inset ${edge} ${innerColor[i] || innerColor[i - 2] || innerColor[0]}`).join(", ") 10 | : undefined; 11 | 12 | return css` 13 | margin: ${width}px; 14 | box-shadow: ${[border, innerBorder].filter(Boolean).join(", ")}; 15 | `; 16 | }; 17 | 18 | export type { BorderColors }; 19 | -------------------------------------------------------------------------------- /client/views/Hub/FindingGame.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { CLIENT_EVENT_NAME } from "../../../shared/constants"; 3 | import { languageAtom } from "../../atoms"; 4 | import Button from "../../components/Button"; 5 | import CenterizedGrid from "../../components/CenterizedGrid"; 6 | import Loading from "../../components/Loading"; 7 | import socket from "../../services/socket"; 8 | 9 | const FindingGame = (): JSX.Element => { 10 | const [language] = useAtom(languageAtom); 11 | 12 | const cancel = () => socket.emit(CLIENT_EVENT_NAME.CancelFindGame); 13 | 14 | return ( 15 | 16 | 17 | 20 | 21 | ); 22 | }; 23 | 24 | export default FindingGame; 25 | -------------------------------------------------------------------------------- /client/views/Hub/Room/Members/Member.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { useAtom } from "jotai"; 3 | import { roomAtom } from "../../../../atoms"; 4 | import Icon from "../../../../components/Icon"; 5 | import { keyStyle, MemberName, StyledMember } from "./styles"; 6 | 7 | type MemberProps = { 8 | id: string; 9 | name: string; 10 | }; 11 | 12 | const Member = ({ id, name }: MemberProps): JSX.Element => { 13 | const [room] = useAtom(roomAtom); 14 | 15 | return ( 16 | 17 | 24 | {name} 25 | {id === room?.owner && } 26 | 27 | ); 28 | }; 29 | 30 | export default Member; 31 | -------------------------------------------------------------------------------- /client/views/Game/Status/Timer/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { useInTurn } from "../../../../hooks"; 3 | import { countdownKeyframes, StyledTimer } from "./styles"; 4 | 5 | type TimerProps = { 6 | id: string; 7 | timePerTurn: number; 8 | fluid?: boolean; 9 | }; 10 | 11 | const Timer = ({ id, timePerTurn, fluid = false }: TimerProps): JSX.Element => { 12 | const shouldShow = useInTurn(id); 13 | 14 | return ( 15 | <> 16 | {shouldShow && ( 17 | 28 | )} 29 | 30 | ); 31 | }; 32 | 33 | export default Timer; 34 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/BoxOfCard/ChargePointBar/styles.ts: -------------------------------------------------------------------------------- 1 | import { css, keyframes } from "@emotion/react"; 2 | import { centerizeStyle } from "../../../../../styles"; 3 | import { BOX_OF_CARD_SIZE } from "../constants"; 4 | 5 | export const bouncingKeyframes = keyframes` 6 | \ 0%, \ 100% { 7 | transform: translateX(calc(-50% - 4px)); 8 | } 9 | 10 | \ 10%, \ 80% { 11 | transform: translate(calc(-50% - 4px), 6px); 12 | } 13 | 14 | \ 30% { 15 | transform: translate(calc(-50% - 4px), 18px); 16 | } 17 | 18 | \ 60%, \ 90% { 19 | transform: translate(calc(-50% - 4px), -12px); 20 | } 21 | 22 | \ 70% { 23 | transform: translate(calc(-50% - 4px), -24px) 24 | } 25 | `; 26 | 27 | export const chargePointBarStyle = css` 28 | ${centerizeStyle}; 29 | top: calc(50% - ${BOX_OF_CARD_SIZE / 2}px); 30 | transform: translateX(calc(-50% - 4px)); 31 | `; 32 | -------------------------------------------------------------------------------- /client/components/ProgressBar/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { COLOR } from "../../constants"; 3 | import { pixelBorderStyle } from "../../styles"; 4 | import { shadeColor, tintColor } from "../../utils"; 5 | 6 | export const StyledProgressBar = styled.div` 7 | position: relative; 8 | ${pixelBorderStyle(4, [COLOR.Normal])}; 9 | margin: 8px; 10 | height: 16px; 11 | `; 12 | 13 | export const ProgressBarCurrentValue = styled.div` 14 | background-color: ${shadeColor(COLOR.Safe, 20)}; 15 | box-shadow: inset 0px 8px 0px 0px ${tintColor(COLOR.Safe, 20)}; 16 | transition: width 300ms; 17 | height: 100%; 18 | `; 19 | 20 | export const ProgressBarUnderlayValue = styled(ProgressBarCurrentValue)` 21 | transition-delay: 600ms; 22 | position: absolute; 23 | top: 0; 24 | background-color: ${COLOR.Danger}; 25 | box-shadow: none; 26 | z-index: -1; 27 | `; 28 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/BoxOfCard/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { SERVER_EVENT_NAME } from "../../../../../shared/constants"; 3 | import { useListenServerEvent } from "../../../../hooks"; 4 | import { centerizeStyle } from "../../../../styles"; 5 | import ChargePointBar from "./ChargePointBar"; 6 | import BoxSprite from "./BoxSprite"; 7 | import OverchargedAnimation from "./OverchargedAnimation"; 8 | 9 | const BoxOfCard = (): JSX.Element => { 10 | const [shouldDeal, deal] = useState(false); 11 | 12 | const idle = () => deal(false); 13 | 14 | useListenServerEvent(SERVER_EVENT_NAME.GetCards, () => deal(true)); 15 | 16 | return ( 17 |
18 | 19 | 20 | 21 |
22 | ); 23 | }; 24 | 25 | export default BoxOfCard; 26 | -------------------------------------------------------------------------------- /client/components/ProgressBar/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { centerizeStyle } from "../../styles"; 3 | import { ProgressBarCurrentValue, ProgressBarUnderlayValue, StyledProgressBar } from "./styles"; 4 | 5 | type ProgressBarProps = { 6 | max?: number; 7 | current?: number; 8 | suffix?: ReactNode; 9 | className?: string; 10 | }; 11 | 12 | const ProgressBar = ({ max = 100, current = 0, suffix = "", ...props }: ProgressBarProps): JSX.Element => { 13 | return ( 14 | 15 | 16 | 17 |
18 | {current} {suffix} 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default ProgressBar; 25 | -------------------------------------------------------------------------------- /server/entities/Client/State/FindingState.ts: -------------------------------------------------------------------------------- 1 | import ClientState from "."; 2 | import Client from ".."; 3 | import { CLIENT_EVENT_NAME } from "../../../../shared/constants"; 4 | import Server from "../../Server"; 5 | 6 | class FindingState extends ClientState { 7 | constructor(client: Client) { 8 | super(client); 9 | this.cancelFindGame = this.cancelFindGame.bind(this); 10 | } 11 | 12 | private cancelFindGame() { 13 | Server.getInstance().dequeueClient(this.client); 14 | } 15 | 16 | public enter(): void { 17 | this.socket.on(CLIENT_EVENT_NAME.CancelFindGame, this.cancelFindGame); 18 | this.socket.on("disconnect", this.cancelFindGame); 19 | } 20 | 21 | public exit(): void { 22 | this.socket.off(CLIENT_EVENT_NAME.CancelFindGame, this.cancelFindGame); 23 | this.socket.off("disconnect", this.cancelFindGame); 24 | } 25 | } 26 | 27 | export default FindingState; 28 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import Emotion from "../../../components/Emotion"; 3 | import socket from "../../../services/socket"; 4 | import { xCenterStyle } from "../../../styles"; 5 | import BoxOfCard from "./BoxOfCard"; 6 | import DeckCounter from "./DeckCounter"; 7 | import RecentPlayedCard from "./RecentPlayedCard"; 8 | import SpellAction from "./SpellAction"; 9 | import { StyledGameBoard } from "./styles"; 10 | 11 | const GameBoard = (): JSX.Element => { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 27 | 28 | ); 29 | }; 30 | 31 | export default GameBoard; 32 | -------------------------------------------------------------------------------- /server/entities/Client/State/InTurnState.ts: -------------------------------------------------------------------------------- 1 | import Client from ".."; 2 | import { SERVER_EVENT_NAME, CLIENT_EVENT_NAME } from "../../../../shared/constants"; 3 | import Game from "../../Game"; 4 | import Player from "../../Player"; 5 | import InGameState from "./InGameState"; 6 | 7 | class InTurnState extends InGameState { 8 | private playCard: (id: string) => void; 9 | 10 | constructor(client: Client, player: Player, game: Game) { 11 | super(client, player, game); 12 | this.playCard = this.player.playCard.bind(this.player); 13 | } 14 | 15 | public enter(): void { 16 | super.enter(); 17 | 18 | this.socket.emit(SERVER_EVENT_NAME.Notify, "notiInTurn", "Info"); 19 | this.socket.on(CLIENT_EVENT_NAME.PlayCard, this.playCard); 20 | } 21 | 22 | public exit(): void { 23 | super.exit(); 24 | this.socket.off(CLIENT_EVENT_NAME.PlayCard, this.playCard); 25 | } 26 | } 27 | 28 | export default InTurnState; 29 | -------------------------------------------------------------------------------- /client/views/Hub/Header/About.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import ReactMarkdown from "react-markdown"; 3 | import { languageAtom } from "../../../atoms"; 4 | import Dialog from "../../../components/Dialog"; 5 | import Icon from "../../../components/Icon"; 6 | import useShowDialog from "../../../hooks/useShowDialog"; 7 | import { clickableIconStyle } from "../../../styles"; 8 | 9 | const About = (): JSX.Element => { 10 | const [shouldDialogShow, dialogAction] = useShowDialog(); 11 | const [language] = useAtom(languageAtom); 12 | 13 | return ( 14 | <> 15 | 16 | 17 |
18 | {language.aboutDescription} 19 |
20 |
21 | 22 | ); 23 | }; 24 | 25 | export default About; 26 | -------------------------------------------------------------------------------- /client/views/Game/Status/Spells/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | import { SerializedStyles } from "@emotion/utils"; 4 | import { COLOR } from "../../../../constants"; 5 | 6 | const BADGE_SIZE = 14; 7 | 8 | export const StyledSpells = styled.div` 9 | padding: 4px; 10 | display: flex; 11 | gap: 6px; 12 | `; 13 | 14 | export const SpellIndicatorBadge = styled.div` 15 | position: absolute; 16 | font-size: ${BADGE_SIZE}px; 17 | background-color: ${COLOR.Normal}; 18 | width: ${BADGE_SIZE}px; 19 | height: ${BADGE_SIZE}px; 20 | top: ${BADGE_SIZE}px; 21 | left: ${BADGE_SIZE}px; 22 | text-align: center; 23 | color: ${COLOR.White}; 24 | `; 25 | 26 | export const spellTriggerAnimation = (trigger = false): SerializedStyles => css` 27 | position: relative; 28 | transition: transform 0.2s ease; 29 | ${trigger && 30 | css` 31 | transform: scale(1.5); 32 | `} 33 | `; 34 | -------------------------------------------------------------------------------- /client/views/Hub/Menu.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { CLIENT_EVENT_NAME } from "../../../shared/constants"; 3 | import { languageAtom, roomAtom } from "../../atoms"; 4 | import Button from "../../components/Button"; 5 | import CenterizedGrid from "../../components/CenterizedGrid"; 6 | import socket from "../../services/socket"; 7 | 8 | const Menu = (): JSX.Element => { 9 | const [room] = useAtom(roomAtom); 10 | const [language] = useAtom(languageAtom); 11 | 12 | const findGame = () => { 13 | socket.emit(CLIENT_EVENT_NAME.FindGame); 14 | }; 15 | 16 | const createRoom = () => { 17 | socket.emit(CLIENT_EVENT_NAME.CreateRoom); 18 | }; 19 | 20 | return ( 21 | 22 | {!room && } 23 | {(!room || room.owner === socket.id) && } 24 | 25 | ); 26 | }; 27 | 28 | export default Menu; 29 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/BoxOfCard/BoxSprite.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { soundAtom } from "../../../../atoms"; 3 | import Sprite from "../../../../components/Sprite"; 4 | import { centerizeStyle } from "../../../../styles"; 5 | import { SpriteProps } from "../../../../types"; 6 | import { ANIMATION_STEP, COMMON_PROPS } from "./constants"; 7 | 8 | type BoxSpriteProps = Pick; 9 | 10 | const BoxSprite = (props: BoxSpriteProps): JSX.Element => { 11 | const [sound] = useAtom(soundAtom); 12 | 13 | const playDealSound = (frame: number) => { 14 | if (frame === 7) { 15 | sound?.play("Pop"); 16 | sound?.play("TakeCard"); 17 | } 18 | }; 19 | 20 | return ( 21 | 29 | ); 30 | }; 31 | 32 | export default BoxSprite; 33 | -------------------------------------------------------------------------------- /client/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import LoadingSpriteSheet from "url:../../assets/sprites/loading_animation.png"; 2 | import Sprite from "../Sprite"; 3 | import { StyledLoading } from "./styles"; 4 | 5 | type LoadingProps = { 6 | text?: string; 7 | scale?: number; 8 | className?: string; 9 | }; 10 | 11 | const Loading = ({ text, scale, className }: LoadingProps): JSX.Element => { 12 | return ( 13 | 14 | 24 | {text && ( 25 | 31 | {text} 32 | 33 | )} 34 | 35 | ); 36 | }; 37 | 38 | export default Loading; 39 | -------------------------------------------------------------------------------- /client/hooks/useNotify.ts: -------------------------------------------------------------------------------- 1 | import produce from "immer"; 2 | import { useAtom } from "jotai"; 3 | import { StyleVariation } from "../../shared/@types"; 4 | import { generateUniqueId } from "../../shared/utils"; 5 | import { notificationsAtom, soundAtom } from "../atoms"; 6 | 7 | const HIDE_TIMEOUT = 1500; 8 | 9 | export const useNotify = (): ((message: string, variation: StyleVariation) => void) => { 10 | const [sound] = useAtom(soundAtom); 11 | const [notifications, setNotifications] = useAtom(notificationsAtom); 12 | 13 | const notify = (message: string, variation: StyleVariation) => { 14 | const id = generateUniqueId(); 15 | 16 | setNotifications( 17 | produce(notifications, (draft) => { 18 | if (draft.length === 3) draft.shift(); 19 | draft.push({ id, message, variation }); 20 | }) 21 | ); 22 | 23 | sound?.play(variation); 24 | setTimeout(() => setNotifications((notis) => notis.slice(1)), HIDE_TIMEOUT); 25 | }; 26 | 27 | return notify; 28 | }; 29 | -------------------------------------------------------------------------------- /client/views/Notifications/styles.ts: -------------------------------------------------------------------------------- 1 | import { css, SerializedStyles } from "@emotion/react"; 2 | import { StyleVariation } from "../../../shared/@types"; 3 | import { COLOR } from "../../constants"; 4 | import { autoTextColor, shadeColor } from "../../utils"; 5 | 6 | export const notificationStyle = (variation: StyleVariation, pos: number): SerializedStyles => { 7 | const borderColor = shadeColor(COLOR[variation], 70); 8 | 9 | return css` 10 | position: absolute; 11 | width: calc(70% - ${pos * 15}px); 12 | max-width: calc(420px - ${pos * 15}px); 13 | left: 50%; 14 | top: -${pos * 15}px; 15 | color: ${autoTextColor(COLOR[variation])}; 16 | text-align: center; 17 | padding: 8px; 18 | font-size: 14px; 19 | box-shadow: 4px 0px 0px 0px ${borderColor}, -4px 0px 0px 0px ${borderColor}, 0px -4px 0px 0px ${borderColor}, 20 | inset 0px -8px 0px 0px ${shadeColor(COLOR[variation], 30)}; 21 | background-color: ${COLOR[variation]}; 22 | transition: width 0.2s, top 0.2s; 23 | `; 24 | }; 25 | -------------------------------------------------------------------------------- /client/components/Carousel/styles.ts: -------------------------------------------------------------------------------- 1 | import { css, SerializedStyles } from "@emotion/react"; 2 | import styled from "@emotion/styled/"; 3 | import Grid from "../Grid"; 4 | import { CarouselContentProps } from "./types"; 5 | 6 | export const StyledCarousel = styled.div` 7 | box-sizing: border-box; 8 | position: relative; 9 | width: 100%; 10 | overflow: hidden; 11 | min-height: 24px; 12 | `; 13 | 14 | export const CarouselContent = styled(Grid)` 15 | gap: 0px; 16 | width: ${({ items }) => items * 100}%; 17 | height: 100%; 18 | grid-template-columns: repeat(${({ items }) => items}, 1fr); 19 | transform: translateX(calc(-${({ items, current }) => (current * 100) / items}%)); 20 | transition: transform 0.5s; 21 | `; 22 | 23 | export const navigateButtonStyle = (left = true): SerializedStyles => css` 24 | ${left 25 | ? css` 26 | left: 0; 27 | ` 28 | : css` 29 | right: 0; 30 | `} 31 | transform: translateY(-50%) rotate(${left ? 90 : -90}deg); 32 | top: 50%; 33 | z-index: 1; 34 | `; 35 | -------------------------------------------------------------------------------- /client/views/Game/Opponents/Opponent.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { memo } from "react"; 3 | import { PlayerInfo } from "../../../../shared/@types"; 4 | import Emotion from "../../../components/Emotion"; 5 | import { useOnEliminate } from "../../../hooks"; 6 | import { disabledStyle, xCenterStyle } from "../../../styles"; 7 | import SpellAnimation from "../SpellAnimation"; 8 | import Status from "../Status"; 9 | import { OpponentName, StyledOpponent } from "./styles"; 10 | 11 | const Opponent = ({ id, name }: PlayerInfo): JSX.Element => { 12 | const isEliminated = useOnEliminate(id); 13 | 14 | return ( 15 | 16 | 17 | {name} 18 | 19 | 28 | 29 | ); 30 | }; 31 | 32 | export default memo(Opponent); 33 | -------------------------------------------------------------------------------- /client/views/Game/WaitingForOthersDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { useEffect } from "react"; 3 | import { SERVER_EVENT_NAME } from "../../../shared/constants"; 4 | import { languageAtom, musicAtom } from "../../atoms"; 5 | import Dialog from "../../components/Dialog"; 6 | import Loading from "../../components/Loading"; 7 | import useShowDialog from "../../hooks/useShowDialog"; 8 | import socket from "../../services/socket"; 9 | 10 | const WaitingForOthersDialog = (): JSX.Element => { 11 | const [shouldShow, action] = useShowDialog(true); 12 | const [music] = useAtom(musicAtom); 13 | const [language] = useAtom(languageAtom); 14 | 15 | useEffect( 16 | () => 17 | void socket.once(SERVER_EVENT_NAME.NewTurn, () => { 18 | action.hide(); 19 | music?.stop(); 20 | }), 21 | [] 22 | ); 23 | 24 | return ( 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default WaitingForOthersDialog; 32 | -------------------------------------------------------------------------------- /server/entities/Client/State/ReadyCheckState.ts: -------------------------------------------------------------------------------- 1 | import ClientState from "."; 2 | import Client from ".."; 3 | import { CLIENT_EVENT_NAME } from "../../../../shared/constants"; 4 | import ReadyChecker from "../../ReadyChecker"; 5 | 6 | class ReadyCheckState extends ClientState { 7 | private fail: () => void; 8 | 9 | constructor(client: Client, private checker: ReadyChecker) { 10 | super(client); 11 | 12 | this.fail = this.confirm.bind(this, false); 13 | this.confirm = this.confirm.bind(this); 14 | } 15 | 16 | private confirm(ready: boolean) { 17 | this.socket.off(CLIENT_EVENT_NAME.ReadyConfirm, this.confirm); 18 | 19 | if (ready) this.checker.ready(this.client); 20 | else this.checker.fail(this.client); 21 | } 22 | 23 | public enter(): void { 24 | this.socket.on(CLIENT_EVENT_NAME.ReadyConfirm, this.confirm); 25 | this.socket.on("disconnect", this.fail); 26 | } 27 | 28 | public exit(): void { 29 | this.socket.off(CLIENT_EVENT_NAME.ReadyConfirm, this.confirm); 30 | this.socket.off("disconnect", this.fail); 31 | } 32 | } 33 | 34 | export default ReadyCheckState; 35 | -------------------------------------------------------------------------------- /client/views/Game/Status/Spells/SpellIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect, useRef, useState } from "react"; 2 | import { SpellInfo } from "../../../../../shared/@types"; 3 | import Icon from "../../../../components/Icon"; 4 | import { SpellIndicatorBadge, spellTriggerAnimation } from "./styles"; 5 | 6 | type SpellIndicatorType = { 7 | className?: string; 8 | } & Partial; 9 | 10 | const SpellIndicator = ({ duration, name, strength, className }: SpellIndicatorType): JSX.Element => { 11 | const [shouldTrigger, trigger] = useState(false); 12 | const firstRender = useRef(true); 13 | 14 | const stopTransition = () => trigger(false); 15 | 16 | useEffect(() => { 17 | if (firstRender.current) firstRender.current = false; 18 | else trigger(true); 19 | }, [duration, strength]); 20 | 21 | return ( 22 |
23 | 24 | {strength} 25 |
26 | ); 27 | }; 28 | 29 | export default memo(SpellIndicator); 30 | -------------------------------------------------------------------------------- /client/views/Game/Opponents/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { PlayerInfo } from "../../../../shared/@types"; 3 | import { SERVER_EVENT_NAME } from "../../../../shared/constants"; 4 | import EmptySlot from "../../../components/EmptySlot"; 5 | import socket from "../../../services/socket"; 6 | import Opponent from "./Opponent"; 7 | import { StyledOpponents } from "./styles"; 8 | 9 | const Opponents = (): JSX.Element => { 10 | const [opponents, setOpponents] = useState([]); 11 | 12 | useEffect( 13 | () => 14 | void socket.once(SERVER_EVENT_NAME.GetPlayerList, (infos) => 15 | setOpponents(infos.filter((p) => p.id !== socket.id)) 16 | ), 17 | [] 18 | ); 19 | 20 | const showOpponents = () => { 21 | const elm: JSX.Element[] = []; 22 | 23 | for (let i = 0; i < 3; i++) 24 | if (opponents[i]) elm.push(); 25 | else elm.push(); 26 | 27 | return elm; 28 | }; 29 | 30 | return {showOpponents()}; 31 | }; 32 | 33 | export default Opponents; 34 | -------------------------------------------------------------------------------- /client/components/Charger/styles.ts: -------------------------------------------------------------------------------- 1 | import { css, SerializedStyles } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | import { COLOR } from "../../constants"; 4 | import { pixelBorderStyle } from "../../styles"; 5 | import { shadeColor } from "../../utils"; 6 | import Grid from "../Grid"; 7 | import { ChargePointBarState } from "./types"; 8 | 9 | const NODE_BORDER_COLOR = "#2e1710"; 10 | 11 | export const emptyNodeStyle = css` 12 | background-color: ${NODE_BORDER_COLOR}; 13 | box-shadow: inset 0px 8px 0px 0px #a79995; 14 | `; 15 | 16 | export const chargeNodeStyle = (barState: ChargePointBarState, delay: number): SerializedStyles => css` 17 | transition: background-color 0.2s, box-shadow 0.2s; 18 | background-color: ${COLOR[barState]}; 19 | box-shadow: inset 0px -8px 0px 0px ${shadeColor(COLOR[barState], 30)}; 20 | transition-delay: ${delay}s; 21 | `; 22 | 23 | export const StyledChargePointBar = styled(Grid)` 24 | ${pixelBorderStyle(4, ["#ad9587"])}; 25 | width: 105px; 26 | height: 20px; 27 | grid-template-columns: repeat(10, 1fr); 28 | border: 4px solid ${NODE_BORDER_COLOR}; 29 | background-color: ${NODE_BORDER_COLOR}; 30 | `; 31 | -------------------------------------------------------------------------------- /server/entities/Spell/PassiveSpell.ts: -------------------------------------------------------------------------------- 1 | import Spell from "."; 2 | import { PassiveActionInfo } from "../../../shared/@types"; 3 | import { SPELL_NAME } from "../../../shared/constants"; 4 | import Player from "../Player"; 5 | 6 | abstract class PassiveSpell extends Spell { 7 | constructor(name: SPELL_NAME, target: Player, caster: Player) { 8 | super(name, caster, target, -1); 9 | } 10 | 11 | public async trigger(): Promise { 12 | return; 13 | } 14 | 15 | public abstract activate(origin: Spell): AsyncGenerator; 16 | 17 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 18 | protected getCommonInfo(origin: Spell) { 19 | return { 20 | id: this.id, 21 | target: this.caster.id, 22 | attacker: { 23 | name: origin.getCaster().getClient().name, 24 | spell: origin.name, 25 | strength: origin.getStrength(), 26 | }, 27 | defender: { 28 | name: this.caster.getClient().name, 29 | spell: this.name, 30 | strength: this.getStrength(), 31 | }, 32 | }; 33 | } 34 | } 35 | 36 | export default PassiveSpell; 37 | -------------------------------------------------------------------------------- /server/entities/Spell/ShieldSpell.ts: -------------------------------------------------------------------------------- 1 | import Spell from "."; 2 | import { PassiveActionInfo } from "../../../shared/@types"; 3 | import { PASSIVE_ACTION, SPELL_NAME } from "../../../shared/constants"; 4 | import Player from "../Player"; 5 | import PassiveSpell from "./PassiveSpell"; 6 | 7 | class ShieldSpell extends PassiveSpell { 8 | constructor(chargePoint: number, caster: Player) { 9 | super(SPELL_NAME.Shield, caster, caster); 10 | this.strength = chargePoint; 11 | } 12 | 13 | public async *activate(origin: Spell): AsyncGenerator { 14 | const commonInfo = this.getCommonInfo(origin); 15 | 16 | if (origin.getStrength() <= this.getStrength()) { 17 | yield { 18 | ...commonInfo, 19 | action: PASSIVE_ACTION.Block, 20 | message: "shieldBlockMessage", 21 | }; 22 | } else { 23 | yield { 24 | ...commonInfo, 25 | action: PASSIVE_ACTION.ShieldPierce, 26 | message: "shieldPiercedMessage", 27 | }; 28 | origin.setStrength(origin.getStrength() - 1); 29 | await this.target.takeSpell(origin); 30 | } 31 | } 32 | } 33 | 34 | export default ShieldSpell; 35 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/BoxOfCard/OverchargedAnimation.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { useEffect, useState } from "react"; 3 | import { SERVER_EVENT_NAME } from "../../../../../shared/constants"; 4 | import { soundAtom } from "../../../../atoms"; 5 | import Sprite from "../../../../components/Sprite"; 6 | import { useListenServerEvent } from "../../../../hooks"; 7 | import { centerizeStyle } from "../../../../styles"; 8 | import { ANIMATION_STEP, COMMON_PROPS } from "./constants"; 9 | 10 | const OverchargedAnimation = (): JSX.Element => { 11 | const [isOvercharged, overcharge] = useState(false); 12 | const [sound] = useAtom(soundAtom); 13 | 14 | const stabilize = () => overcharge(false); 15 | 16 | useListenServerEvent(SERVER_EVENT_NAME.Overcharged, () => overcharge(true)); 17 | 18 | useEffect(() => { 19 | if (isOvercharged) sound?.play("Overcharged"); 20 | }, [isOvercharged]); 21 | 22 | return ( 23 | <> 24 | {isOvercharged && ( 25 | 26 | )} 27 | 28 | ); 29 | }; 30 | 31 | export default OverchargedAnimation; 32 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/BoxOfCard/ChargePointBar/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { useState } from "react"; 3 | import { SERVER_EVENT_NAME } from "../../../../../../shared/constants"; 4 | import Charger from "../../../../../components/Charger"; 5 | import { useListenServerEvent } from "../../../../../hooks"; 6 | import { bouncingKeyframes, chargePointBarStyle } from "./styles"; 7 | 8 | const ChargePointBar = (): JSX.Element => { 9 | const [chargePoint, setChargePoint] = useState(0); 10 | const [shouldAnimate, animate] = useState(false); 11 | 12 | const stopAnimation = () => animate(false); 13 | 14 | useListenServerEvent(SERVER_EVENT_NAME.ChargePointChanged, (point: number) => setChargePoint(point)); 15 | useListenServerEvent(SERVER_EVENT_NAME.GetCards, () => animate(true)); 16 | 17 | return ( 18 | 30 | ); 31 | }; 32 | 33 | export default ChargePointBar; 34 | -------------------------------------------------------------------------------- /client/views/Game/Status/Actions/EmotionSelector.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { useAtom } from "jotai"; 3 | import { CLIENT_EVENT_NAME, EMOTION } from "../../../../../shared/constants"; 4 | import { languageAtom } from "../../../../atoms"; 5 | import Button from "../../../../components/Button"; 6 | import DropDown from "../../../../components/Dropdown"; 7 | import Icon from "../../../../components/Icon"; 8 | import socket from "../../../../services/socket"; 9 | import { iconButtonStyle } from "./styles"; 10 | 11 | const EmotionSelector = (): JSX.Element => { 12 | const [language] = useAtom(languageAtom); 13 | 14 | return ( 15 | } 21 | > 22 | {Object.values(EMOTION).map((emo) => ( 23 | 32 | ))} 33 | 34 | ); 35 | }; 36 | 37 | export default EmotionSelector; 38 | -------------------------------------------------------------------------------- /client/views/Hub/Room/LeaveRoomButton.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { CLIENT_EVENT_NAME } from "../../../../shared/constants"; 3 | import { languageAtom } from "../../../atoms"; 4 | import Button from "../../../components/Button"; 5 | import Dialog from "../../../components/Dialog"; 6 | import useShowDialog from "../../../hooks/useShowDialog"; 7 | import socket from "../../../services/socket"; 8 | 9 | const LeaveRoomButton = (): JSX.Element => { 10 | const [shouldDialogShow, dialogAction] = useShowDialog(); 11 | const [language] = useAtom(languageAtom); 12 | 13 | const leave = () => { 14 | socket.emit(CLIENT_EVENT_NAME.LeaveRoom); 15 | dialogAction.hide(); 16 | }; 17 | 18 | return ( 19 | <> 20 | 23 | 32 |

{language.leaveMessage}

33 |
34 | 35 | ); 36 | }; 37 | 38 | export default LeaveRoomButton; 39 | -------------------------------------------------------------------------------- /client/components/IntegrateInput/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | import { StyleVariation } from "../../../shared/@types"; 4 | import { COLOR } from "../../constants"; 5 | import { pixelBorderStyle } from "../../styles"; 6 | import { shadeColor } from "../../utils"; 7 | import Button from "../Button"; 8 | 9 | export const StyledIntegratedInput = styled.div<{ variation: StyleVariation }>` 10 | display: flex; 11 | ${({ variation }) => 12 | css` 13 | ${pixelBorderStyle(2, [shadeColor(COLOR[variation], 70)])} 14 | `} 15 | `; 16 | 17 | export const InnerButton = styled(Button)` 18 | margin: 2px 2px 2px 0px; 19 | padding: 2px; 20 | box-shadow: none; 21 | margin: 0; 22 | border: none; 23 | ${({ variation = "Primary" }) => css` 24 | border-left: 2px solid ${shadeColor(COLOR[variation], 70)}; 25 | 26 | &:active { 27 | background-color: ${shadeColor(COLOR[variation], 20)}; 28 | border-color: ${shadeColor(COLOR[variation], 70)}; 29 | ${pixelBorderStyle(0, ["#000"])}; 30 | } 31 | `} 32 | `; 33 | 34 | export const InnerInput = styled.input` 35 | border: none; 36 | margin: 2px 0px 2px 2px; 37 | flex-grow: 1; 38 | padding: 2px; 39 | cursor: text; 40 | `; 41 | -------------------------------------------------------------------------------- /server/entities/Game/Deck.ts: -------------------------------------------------------------------------------- 1 | import { SPELL_NAME } from "../../../shared/constants"; 2 | import Card from "../Card"; 3 | 4 | class Deck { 5 | private cards: Card[] = []; 6 | 7 | constructor(empty = false) { 8 | if (!empty) { 9 | for (let i = 0; i < 10; i++) { 10 | Object.values(SPELL_NAME).forEach((eff) => { 11 | this.cards.push(new Card(i, eff)); 12 | this.cards.push(new Card(-i, eff)); 13 | }); 14 | } 15 | this.shuffle(); 16 | } 17 | } 18 | 19 | // Credit: https://stackoverflow.com/a/12646864/8884948 20 | public shuffle(): void { 21 | for (let i = this.cards.length - 1; i > 0; i--) { 22 | const j = Math.floor(Math.random() * (i + 1)); 23 | [this.cards[i], this.cards[j]] = [this.cards[j], this.cards[i]]; 24 | } 25 | } 26 | 27 | public copy(source: Deck): void { 28 | this.cards = [...source.cards]; 29 | } 30 | 31 | public pop(): Card | undefined { 32 | return this.cards.pop(); 33 | } 34 | 35 | public push(card: Card): void { 36 | this.cards.push(card); 37 | } 38 | 39 | public getSize(): number { 40 | return this.cards.length; 41 | } 42 | 43 | public clear(): void { 44 | this.cards = []; 45 | } 46 | } 47 | 48 | export default Deck; 49 | -------------------------------------------------------------------------------- /client/components/Carousel/index.tsx: -------------------------------------------------------------------------------- 1 | import { Children, ReactNode, useState } from "react"; 2 | import Icon from "../Icon"; 3 | import CarouselNavigator from "./CarouselNavigator"; 4 | import { CarouselContent, StyledCarousel, navigateButtonStyle } from "./styles"; 5 | 6 | type CarouselProps = { 7 | className?: string; 8 | children: ReactNode; 9 | }; 10 | 11 | const Carousel = ({ children, className }: CarouselProps): JSX.Element => { 12 | const [numOfChildren] = useState(Children.count(children)); 13 | const [current, setCurrent] = useState(0); 14 | 15 | const onNext = () => setCurrent((current + 1) % numOfChildren); 16 | const onPrev = () => setCurrent((current + numOfChildren - 1) % numOfChildren); 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default Carousel; 31 | export * from "./CarouselItem"; 32 | -------------------------------------------------------------------------------- /client/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | import { StyleVariation } from "../../shared/@types"; 4 | import { COLOR } from "../constants"; 5 | import { pixelBorderStyle } from "../styles"; 6 | import { autoTextColor, shadeColor, tintColor } from "../utils"; 7 | 8 | type ButtonProps = { 9 | variation?: StyleVariation; 10 | disabled?: boolean; 11 | }; 12 | 13 | const Button = styled.button` 14 | ${({ variation = "Primary", disabled = false }) => css` 15 | ${pixelBorderStyle(2, [shadeColor(COLOR[variation], 70)])}; 16 | background-color: ${COLOR[variation]}; 17 | color: ${autoTextColor(COLOR[variation])}; 18 | border: 2px solid ${COLOR[variation]}; 19 | border-style: solid none; 20 | cursor: ${disabled ? "initial" : "pointer"}; 21 | 22 | &:hover { 23 | border-bottom-color: ${disabled ? COLOR[variation] : "rgba(0, 0, 0, 0.4)"}; 24 | } 25 | 26 | &:active { 27 | background-color: ${tintColor(COLOR[variation], 25)}; 28 | border-color: ${tintColor(COLOR[variation], 25)}; 29 | ${pixelBorderStyle(2, [shadeColor(COLOR[variation], 50)])}; 30 | } 31 | `} 32 | outline: none; 33 | padding: 0px 6px; 34 | `; 35 | 36 | export default Button; 37 | -------------------------------------------------------------------------------- /server/entities/Spell/MirrorSpell.ts: -------------------------------------------------------------------------------- 1 | import Spell from "."; 2 | import { PassiveActionInfo } from "../../../shared/@types"; 3 | import { SPELL_NAME, PASSIVE_ACTION } from "../../../shared/constants"; 4 | import Player from "../Player"; 5 | import PassiveSpell from "./PassiveSpell"; 6 | 7 | class MirrorSpell extends PassiveSpell { 8 | constructor(chargePoint: number, caster: Player) { 9 | super(SPELL_NAME.Mirror, caster, caster); 10 | this.strength = chargePoint; 11 | } 12 | 13 | public async *activate(origin: Spell): AsyncGenerator { 14 | const commonInfo = this.getCommonInfo(origin); 15 | 16 | if (origin.getStrength() <= this.getStrength()) { 17 | yield { 18 | ...commonInfo, 19 | action: PASSIVE_ACTION.Reflect, 20 | message: "mirrorReflectMessage", 21 | }; 22 | const caster = origin.getCaster(); 23 | origin.setTarget(caster); 24 | origin.setCaster(this.target); 25 | await caster.takeSpell(origin); 26 | } else { 27 | yield { 28 | ...commonInfo, 29 | action: PASSIVE_ACTION.MirrorPierce, 30 | message: "mirrorPiercedMessage", 31 | }; 32 | await this.target.takeSpell(origin); 33 | } 34 | } 35 | } 36 | 37 | export default MirrorSpell; 38 | -------------------------------------------------------------------------------- /shared/constants/event.ts: -------------------------------------------------------------------------------- 1 | export enum SERVER_EVENT_NAME { 2 | Notify = "notify", 3 | UpdateGameMatchingStatus = "update game matcher status", 4 | GetGameSettings = "get game settings", 5 | GetPlayerList = "get player list", 6 | NewGame = "new game", 7 | GetCards = "get cards", 8 | CardPlayed = "card played", 9 | ChargePointChanged = "charge point changed", 10 | Overcharged = "overcharged", 11 | PlayerEliminated = "player eliminated", 12 | GameOver = "game over", 13 | NewTurn = "new turn", 14 | HitPointChanged = "hit point changed", 15 | TakeSpell = "take spell", 16 | ActivatePassive = "activate passive", 17 | GetRoomInfo = "get room info", 18 | FriendJoined = "friend joined", 19 | FriendLeft = "friend left", 20 | LeftRoom = "left room", 21 | JoinedRoom = "join room", 22 | Purify = "purify", 23 | EmotionExpressed = "emotion expressed", 24 | } 25 | 26 | export enum CLIENT_EVENT_NAME { 27 | Rename = "rename", 28 | FindGame = "find game", 29 | CancelFindGame = "cancel find game", 30 | ReadyConfirm = "ready confirm", 31 | PlayCard = "play card", 32 | LeaveGame = "leave game", 33 | CreateRoom = "create room", 34 | JoinRoom = "join room", 35 | LeaveRoom = "leave room", 36 | Kick = "kick", 37 | ExpressEmotion = "express emotion", 38 | } 39 | -------------------------------------------------------------------------------- /client/views/ReconnectDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { useState } from "react"; 3 | import { languageAtom } from "../atoms"; 4 | import Dialog from "../components/Dialog"; 5 | import Loading from "../components/Loading"; 6 | import { useListenServerEvent } from "../hooks"; 7 | import useShowDialog from "../hooks/useShowDialog"; 8 | import socket from "../services/socket"; 9 | 10 | const ReconnectDialog = (): JSX.Element => { 11 | const [shouldShow, action] = useShowDialog(); 12 | const [shouldReconnect, reconnect] = useState(false); 13 | const [language] = useAtom(languageAtom); 14 | 15 | const onReconnect = () => { 16 | socket.connect(); 17 | reconnect(true); 18 | }; 19 | 20 | useListenServerEvent("connect_error", () => { 21 | action.reveal(); 22 | reconnect(false); 23 | }); 24 | 25 | useListenServerEvent("connect", action.hide); 26 | 27 | return ( 28 | 36 | {shouldReconnect ? :

{language.failConnect}

} 37 |
38 | ); 39 | }; 40 | 41 | export default ReconnectDialog; 42 | -------------------------------------------------------------------------------- /client/views/Game/Status/Actions/LeaveButton.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { CLIENT_EVENT_NAME } from "../../../../../shared/constants"; 3 | import { languageAtom, routeAtom } from "../../../../atoms"; 4 | import Dialog from "../../../../components/Dialog"; 5 | import Icon from "../../../../components/Icon"; 6 | import { ROUTE } from "../../../../constants"; 7 | import useShowDialog from "../../../../hooks/useShowDialog"; 8 | import socket from "../../../../services/socket"; 9 | import { iconButtonStyle } from "./styles"; 10 | 11 | const LeaveButton = (): JSX.Element => { 12 | const [, setRoute] = useAtom(routeAtom); 13 | const [language] = useAtom(languageAtom); 14 | const [shouldDialogShow, dialogAction] = useShowDialog(); 15 | 16 | const leave = () => { 17 | socket.emit(CLIENT_EVENT_NAME.LeaveGame); 18 | setRoute(ROUTE.Hub); 19 | }; 20 | 21 | return ( 22 | <> 23 | 24 | 33 |

{language.leaveMessage}

34 |
35 | 36 | ); 37 | }; 38 | 39 | export default LeaveButton; 40 | -------------------------------------------------------------------------------- /client/components/Emotion/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import EmotionSprites from "url:../../assets/sprites/emotions.png"; 3 | import { EMOTION, SERVER_EVENT_NAME } from "../../../shared/constants"; 4 | import { useListenServerEvent } from "../../hooks"; 5 | import { centerizeStyle } from "../../styles"; 6 | import Icon from "../Icon"; 7 | import Sprite from "../Sprite"; 8 | 9 | type EmotionProps = { 10 | id: string; 11 | className?: string; 12 | }; 13 | 14 | const SIZE = [32, 32] as [number, number]; 15 | const SPRITE_POS = [...Object.values(EMOTION)]; 16 | 17 | const Emotion = ({ id, className }: EmotionProps): JSX.Element => { 18 | const [emotion, setEmotion] = useState(null); 19 | 20 | useListenServerEvent(SERVER_EVENT_NAME.EmotionExpressed, (sender: string, emotion: EMOTION) => { 21 | if (sender === id) setEmotion(emotion); 22 | }); 23 | 24 | return ( 25 | <> 26 | {emotion && ( 27 | 28 | setEmotion(null)} 30 | steps={6} 31 | src={EmotionSprites} 32 | size={SIZE} 33 | row={SPRITE_POS.indexOf(emotion)} 34 | loop={3} 35 | css={centerizeStyle} 36 | /> 37 | 38 | )} 39 | 40 | ); 41 | }; 42 | 43 | export default Emotion; 44 | -------------------------------------------------------------------------------- /client/views/Game/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { CLIENT_EVENT_NAME } from "../../../shared/constants"; 3 | import BoxOfCardSprites from "url:../../assets/sprites/box_of_cards.png"; 4 | import ContentFrameSprite from "url:../../assets/sprites/card_content_frame.png"; 5 | import IconSprites from "url:../../assets/sprites/icons.png"; 6 | import SpellAnimations from "url:../../assets/sprites/spell_animations.png"; 7 | import EmotionSprites from "url:../../assets/sprites/emotions.png"; 8 | import withSpriteLoading from "../../HOCs/withSpriteLoading"; 9 | import socket from "../../services/socket"; 10 | import GameBoard from "./GameBoard"; 11 | import GameOverDialog from "./GameOverDialog"; 12 | import Opponents from "./Opponents"; 13 | import Player from "./Player"; 14 | import { StyledGame } from "./styles"; 15 | import WaitingForOthersDialog from "./WaitingForOthersDialog"; 16 | 17 | const Game = (): JSX.Element => { 18 | useEffect(() => void socket.emit(CLIENT_EVENT_NAME.ReadyConfirm, true), []); 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default withSpriteLoading(Game, [ 32 | BoxOfCardSprites, 33 | ContentFrameSprite, 34 | IconSprites, 35 | SpellAnimations, 36 | EmotionSprites, 37 | ]); 38 | -------------------------------------------------------------------------------- /client/views/Hub/Room/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { CLIENT_EVENT_NAME } from "../../../../shared/constants"; 3 | import { languageAtom, roomAtom } from "../../../atoms"; 4 | import H1 from "../../../components/H1"; 5 | import IntegrateInput from "../../../components/IntegrateInput"; 6 | import { useNotify } from "../../../hooks/useNotify"; 7 | import socket from "../../../services/socket"; 8 | import LeaveRoomButton from "./LeaveRoomButton"; 9 | import Members from "./Members"; 10 | import { StyledRoom } from "./styles"; 11 | 12 | const Room = (): JSX.Element => { 13 | const notify = useNotify(); 14 | const [room] = useAtom(roomAtom); 15 | const [language] = useAtom(languageAtom); 16 | 17 | const join = (id?: string) => { 18 | if (id) socket.emit(CLIENT_EVENT_NAME.JoinRoom, id); 19 | else notify("errRoomNotFound", "Danger"); 20 | }; 21 | 22 | return ( 23 | 24 | {room ? ( 25 | <> 26 |

27 | {language.roomCode}: {room.id} 28 |

29 | 30 | 31 | 32 | ) : ( 33 | 34 | {language.join} 35 | 36 | )} 37 |
38 | ); 39 | }; 40 | 41 | export default Room; 42 | -------------------------------------------------------------------------------- /client/utils/color.ts: -------------------------------------------------------------------------------- 1 | import { COLOR } from "../constants"; 2 | 3 | type RGB = [number, number, number]; 4 | 5 | export const hexToRgb = (color: string): RGB => { 6 | if (color.charAt(0) === "#") color = color.substring(1); 7 | 8 | const bigInt = parseInt(color, 16); 9 | const r = (bigInt >> 16) & 255; 10 | const g = (bigInt >> 8) & 255; 11 | const b = bigInt & 255; 12 | 13 | return [r, g, b]; 14 | }; 15 | 16 | export const rgbToHex = (color: RGB): string => 17 | "#" + 18 | color.reduce((acc, cur) => { 19 | const hex = cur.toString(16); 20 | return acc + (hex.length === 1 ? "0" + hex : hex); 21 | }, ""); 22 | 23 | export const isDarkColor = (color: string): boolean => { 24 | const [r, g, b] = hexToRgb(color); 25 | const hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)); 26 | 27 | return hsp <= 127.5; 28 | }; 29 | 30 | export const shadeColor = (color: string, percent: number): string => 31 | rgbToHex(hexToRgb(color).map((c) => Math.round(c * (1 - percent / 100))) as RGB); 32 | 33 | export const tintColor = (color: string, percent: number): string => 34 | rgbToHex(hexToRgb(color).map((c) => Math.round(c + ((255 - c) * percent) / 100)) as RGB); 35 | 36 | export const randomHexColor = (): string => "#" + Math.floor(Math.random() * 16777215).toString(16); 37 | 38 | export const autoTextColor = (color: COLOR): COLOR => (isDarkColor(color) ? COLOR.White : COLOR.Black); 39 | -------------------------------------------------------------------------------- /client/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import IconSprites from "url:../assets/sprites/icons.png"; 2 | import { SPELL_NAME } from "../../shared/constants"; 3 | import { SpriteProps } from "../types"; 4 | import Sprite from "./Sprite"; 5 | 6 | type IconName = 7 | | "deck" 8 | | "triangle" 9 | | "gamepad" 10 | | "key" 11 | | "exit" 12 | | "sleep_bubble" 13 | | "charge" 14 | | "consume" 15 | | "cog" 16 | | "sound" 17 | | "music" 18 | | "book" 19 | | "vnFlag" 20 | | "usFlag" 21 | | "info" 22 | | "bubble_text" 23 | | "smiley" 24 | | SPELL_NAME; 25 | 26 | const SIZE = [24, 24] as [number, number]; 27 | 28 | type IconProps = Omit & { 29 | name: IconName; 30 | }; 31 | 32 | const SPRITE_POS: IconName[] = [ 33 | "charge", 34 | "consume", 35 | SPELL_NAME.Heal, 36 | SPELL_NAME.Mirror, 37 | SPELL_NAME.Poison, 38 | SPELL_NAME.Punch, 39 | SPELL_NAME.Shield, 40 | "deck", 41 | "triangle", 42 | "gamepad", 43 | "key", 44 | "exit", 45 | "sleep_bubble", 46 | "cog", 47 | "sound", 48 | "music", 49 | "book", 50 | "vnFlag", 51 | "usFlag", 52 | "info", 53 | "bubble_text", 54 | "smiley", 55 | ]; 56 | 57 | const Icon = ({ name, children, ...props }: IconProps): JSX.Element => { 58 | return ( 59 | 60 | {children} 61 | 62 | ); 63 | }; 64 | 65 | export default Icon; 66 | -------------------------------------------------------------------------------- /client/views/Game/Status/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect, useState } from "react"; 2 | import { DEFAULT_MAX_HP, DEFAULT_TIME_PER_TURN } from "../../../../shared/config"; 3 | import { SERVER_EVENT_NAME } from "../../../../shared/constants"; 4 | import socket from "../../../services/socket"; 5 | import Actions from "./Actions"; 6 | import HitPointBar from "./HitPointBar"; 7 | import Spells from "./Spells"; 8 | import { horizontalStatusStyle, StyledStatus } from "./styles"; 9 | import Timer from "./Timer"; 10 | 11 | type StatusProps = { 12 | id: string; 13 | horizontal?: boolean; 14 | }; 15 | 16 | const Status = ({ id, horizontal = false }: StatusProps): JSX.Element => { 17 | const [maxHP, setMaxHP] = useState(DEFAULT_MAX_HP); 18 | const [timePerTurn, setTimePerTurn] = useState(DEFAULT_TIME_PER_TURN); 19 | 20 | useEffect( 21 | () => 22 | void socket.once(SERVER_EVENT_NAME.GetGameSettings, (maxHP, timePerTurn) => { 23 | setMaxHP(maxHP); 24 | setTimePerTurn(timePerTurn); 25 | }), 26 | [] 27 | ); 28 | 29 | return ( 30 | 31 | 32 | 33 | {horizontal && } 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default memo(Status); 40 | -------------------------------------------------------------------------------- /client/components/Dialog/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | import { StyleVariation } from "../../../shared/@types"; 4 | import { COLOR } from "../../constants"; 5 | import { autoTextColor, hexToRgb } from "../../utils"; 6 | import H1 from "../H1"; 7 | import Page from "../Page"; 8 | 9 | export const StyledDialog = styled(Page)<{ show: boolean }>` 10 | background-color: rgba(${hexToRgb(COLOR.Normal).join(", ")}, 0.4); 11 | overflow: auto; 12 | ${({ show }) => 13 | show 14 | ? css` 15 | opacity: 1; 16 | z-index: 1; 17 | transition: opacity 0.2s; 18 | ` 19 | : css` 20 | opacity: 0; 21 | z-index: -1; 22 | `} 23 | `; 24 | 25 | export const DialogContent = styled.div` 26 | background-color: ${COLOR.White}; 27 | margin: 25% auto; 28 | width: 80%; 29 | color: ${COLOR.Black}; 30 | border: 4px solid; 31 | 32 | & > * { 33 | padding: 8px; 34 | } 35 | `; 36 | 37 | export const DialogHeader = styled(H1)<{ variation: StyleVariation }>` 38 | text-transform: uppercase; 39 | ${({ variation }) => css` 40 | background-color: ${COLOR[variation]}; 41 | color: ${autoTextColor(COLOR[variation])}; 42 | `} 43 | `; 44 | 45 | export const DialogFooter = styled.div` 46 | display: flex; 47 | gap: 8px; 48 | justify-content: flex-end; 49 | 50 | & button { 51 | text-transform: capitalize; 52 | } 53 | `; 54 | -------------------------------------------------------------------------------- /server/entities/Client/index.ts: -------------------------------------------------------------------------------- 1 | import { ClientSocket, ClientInfo } from "../../../shared/@types"; 2 | import Server from "../Server"; 3 | import ClientState from "./State"; 4 | import IdleState from "./State/IdleState"; 5 | 6 | class Client { 7 | private state: ClientState; 8 | 9 | constructor(private socket: ClientSocket, public name = "player") { 10 | this.state = new IdleState(this); 11 | 12 | this.state.enter(); 13 | this.socket.on("disconnect", this.onDisconnect.bind(this)); 14 | } 15 | 16 | public getId(): string { 17 | return this.socket.id; 18 | } 19 | 20 | public getSocket(): ClientSocket { 21 | return this.socket; 22 | } 23 | 24 | public getInfo(): ClientInfo { 25 | return { 26 | id: this.getId(), 27 | name: this.name, 28 | }; 29 | } 30 | 31 | public getState(): ClientState { 32 | return this.state; 33 | } 34 | 35 | private onDisconnect() { 36 | // because player is maybe in room while in game 37 | const room = Server.getInstance().getRoomHasClient(this); 38 | if (room) room.remove(this); 39 | } 40 | 41 | public changeState(state: ClientState): void { 42 | const oldState = this.state.constructor.name; 43 | const newState = state.constructor.name; 44 | console.log(`${this.getId()} changed state from ${oldState} to ${newState}`); 45 | this.state.exit(); 46 | this.state = state; 47 | state.enter(); 48 | } 49 | } 50 | 51 | export default Client; 52 | -------------------------------------------------------------------------------- /client/views/Hub/Header/Tutorial/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { languageAtom } from "../../../../atoms"; 3 | import Carousel, { CarouselItem } from "../../../../components/Carousel"; 4 | import Dialog from "../../../../components/Dialog"; 5 | import Icon from "../../../../components/Icon"; 6 | import { useLocalStorage } from "../../../../hooks"; 7 | import useShowDialog from "../../../../hooks/useShowDialog"; 8 | import { clickableIconStyle } from "../../../../styles"; 9 | import GameplayTutorial from "./GameplayTutorial"; 10 | import SpellDictionary from "./SpellDictionary"; 11 | 12 | const Tutorial = (): JSX.Element => { 13 | const [shouldShowOnLoad, showOnLoad] = useLocalStorage("showOnLoad", 1); 14 | const [shouldDialogShow, dialogAction] = useShowDialog(!!shouldShowOnLoad); 15 | const [language] = useAtom(languageAtom); 16 | 17 | const close = () => { 18 | dialogAction.hide(); 19 | showOnLoad(0); 20 | }; 21 | 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default Tutorial; 40 | -------------------------------------------------------------------------------- /server/entities/Spell/SpellFactory.ts: -------------------------------------------------------------------------------- 1 | import { SPELL_NAME } from "../../../shared/constants"; 2 | import Player from "../Player"; 3 | import HealSpell from "./HealSpell"; 4 | import MirrorSpell from "./MirrorSpell"; 5 | import PoisonSpell from "./PoisonSpell"; 6 | import PunchSpell from "./PunchSpell"; 7 | import ShieldSpell from "./ShieldSpell"; 8 | 9 | class SpellFactory { 10 | public static async create(name: SPELL_NAME, chargePoint: number, players: Player[], caster: Player): Promise { 11 | switch (name) { 12 | case SPELL_NAME.Punch: { 13 | for (const p of players) if (p !== caster) await p.takeSpell(new PunchSpell(chargePoint, p, caster)); 14 | break; 15 | } 16 | case SPELL_NAME.Heal: { 17 | await caster.takeSpell(new HealSpell(chargePoint, caster)); 18 | break; 19 | } 20 | case SPELL_NAME.Poison: { 21 | for (const p of players) if (p !== caster) await p.takeSpell(new PoisonSpell(chargePoint, p, caster)); 22 | break; 23 | } 24 | case SPELL_NAME.Shield: { 25 | await caster.takeSpell(new ShieldSpell(chargePoint, caster)); 26 | break; 27 | } 28 | case SPELL_NAME.Mirror: { 29 | await caster.takeSpell(new MirrorSpell(chargePoint, caster)); 30 | break; 31 | } 32 | case SPELL_NAME.Void: { 33 | break; 34 | } 35 | default: { 36 | throw new Error("Invalid spell"); 37 | } 38 | } 39 | } 40 | } 41 | 42 | export default SpellFactory; 43 | -------------------------------------------------------------------------------- /client/views/Game/Status/HitPointBar/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState } from "react"; 2 | import { SERVER_EVENT_NAME } from "../../../../../shared/constants"; 3 | import { getNumberSign } from "../../../../../shared/utils"; 4 | import ProgressBar from "../../../../components/ProgressBar"; 5 | import { COLOR } from "../../../../constants"; 6 | import { useListenServerEvent } from "../../../../hooks"; 7 | import { shadeColor } from "../../../../utils"; 8 | import { HPDiff } from "./styles"; 9 | 10 | type HitPointBarProps = { 11 | id: string; 12 | maxHP: number; 13 | goUp?: boolean; 14 | }; 15 | 16 | const HitPointBar = ({ id, maxHP, goUp }: HitPointBarProps): JSX.Element => { 17 | const [hp, setHP] = useState(maxHP); 18 | const [diff, setDiff] = useState(0); 19 | 20 | useListenServerEvent(SERVER_EVENT_NAME.HitPointChanged, (target: string, newHP: number) => { 21 | if (target === id) { 22 | setHP((oldHP) => { 23 | setDiff(newHP - oldHP); 24 | return newHP; 25 | }); 26 | } 27 | }); 28 | 29 | return ( 30 |
31 | 32 | {diff !== 0 && ( 33 | setDiff(0)} 36 | goUp={goUp} 37 | > 38 | {getNumberSign(diff) + Math.abs(diff)} 39 | 40 | )} 41 |
42 | ); 43 | }; 44 | 45 | export default memo(HitPointBar); 46 | -------------------------------------------------------------------------------- /server/entities/Server/GameMatcher.ts: -------------------------------------------------------------------------------- 1 | import { SERVER_EVENT_NAME } from "../../../shared/constants"; 2 | import { MAX_PLAYERS_PER_GAME } from "../../../shared/config"; 3 | import Client from "../Client"; 4 | import MatchingChecker from "../ReadyChecker/MatchingChecker"; 5 | 6 | const WAIT_FOR_FULL_LOBBY = 5000; 7 | 8 | class GameMatcher { 9 | private queue: Client[] = []; 10 | private timeout!: NodeJS.Timeout; 11 | 12 | private found() { 13 | const clients = this.queue.splice( 14 | 0, 15 | this.queue.length < MAX_PLAYERS_PER_GAME ? this.queue.length : MAX_PLAYERS_PER_GAME 16 | ); 17 | 18 | clients.forEach((c) => c.getSocket().emit(SERVER_EVENT_NAME.UpdateGameMatchingStatus, "Found")); 19 | console.table(clients.map((c) => c.getId())); 20 | new MatchingChecker(clients); 21 | } 22 | 23 | private match() { 24 | clearTimeout(this.timeout); 25 | if (this.queue.length <= 1) return; 26 | if (this.queue.length >= 4) this.found(); 27 | else this.timeout = setTimeout(this.found.bind(this), WAIT_FOR_FULL_LOBBY); 28 | } 29 | 30 | public add(client: Client): void { 31 | this.queue.push(client); 32 | client.getSocket().emit(SERVER_EVENT_NAME.UpdateGameMatchingStatus, "Finding"); 33 | this.match(); 34 | } 35 | 36 | public remove(client: Client): void { 37 | this.queue = this.queue.filter((c) => c !== client); 38 | client.getSocket().emit(SERVER_EVENT_NAME.UpdateGameMatchingStatus, "Canceled"); 39 | this.match(); 40 | } 41 | } 42 | 43 | export default GameMatcher; 44 | -------------------------------------------------------------------------------- /server/entities/Client/State/InGameState.ts: -------------------------------------------------------------------------------- 1 | import ClientState from "."; 2 | import Client from ".."; 3 | import { CLIENT_EVENT_NAME, EMOTION } from "../../../../shared/constants"; 4 | import Game from "../../Game"; 5 | import Player from "../../Player"; 6 | 7 | class InGameState extends ClientState { 8 | private canExpressEmotion = true; 9 | 10 | constructor(client: Client, protected player: Player, protected game: Game) { 11 | super(client); 12 | this.leaveGame = this.leaveGame.bind(this); 13 | this.expressEmotion = this.expressEmotion.bind(this); 14 | } 15 | 16 | private leaveGame(): void { 17 | this.game.removePlayer(this.player); 18 | this.game.eliminatePlayer(this.player); 19 | } 20 | 21 | private expressEmotion(emotion: EMOTION): void { 22 | if (this.canExpressEmotion) { 23 | setTimeout((() => (this.canExpressEmotion = true)).bind(this), 3000); 24 | this.canExpressEmotion = false; 25 | this.game.expressEmotion(this.player, emotion); 26 | } 27 | } 28 | 29 | public enter(): void { 30 | this.socket.on("disconnect", this.leaveGame); 31 | this.socket.on(CLIENT_EVENT_NAME.LeaveGame, this.leaveGame); 32 | this.socket.on(CLIENT_EVENT_NAME.ExpressEmotion, this.expressEmotion); 33 | } 34 | 35 | public exit(): void { 36 | this.socket.off("disconnect", this.leaveGame); 37 | this.socket.off(CLIENT_EVENT_NAME.LeaveGame, this.leaveGame); 38 | this.socket.off(CLIENT_EVENT_NAME.ExpressEmotion, this.expressEmotion); 39 | } 40 | } 41 | 42 | export default InGameState; 43 | -------------------------------------------------------------------------------- /client/components/Dropdown/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from "react"; 2 | import { animated } from "react-spring"; 3 | import { useOnClickOutside, useRevealAnimation } from "../../hooks"; 4 | import Button from "../Button"; 5 | import Icon from "../Icon"; 6 | import { dropDownContentStyle, dropDownIconStyle } from "./styles"; 7 | 8 | type DropDownProps = { 9 | header?: ReactNode; 10 | children: ReactNode; 11 | onTop?: boolean; 12 | className?: string; 13 | }; 14 | 15 | const DropDown = ({ header, children, onTop = false, className }: DropDownProps): JSX.Element => { 16 | const [shouldShow, show] = useState(false); 17 | const ref = useOnClickOutside(() => show(false)); 18 | const transitions = useRevealAnimation(shouldShow); 19 | 20 | const toggleShow = () => show(!shouldShow); 21 | 22 | return ( 23 |
24 |
25 | {!header || typeof header === "string" ? ( 26 | 30 | ) : ( 31 | header 32 | )} 33 |
34 | {transitions.map( 35 | ({ item, props }) => 36 | item && ( 37 | 38 | {children} 39 | 40 | ) 41 | )} 42 |
43 | ); 44 | }; 45 | 46 | export default DropDown; 47 | -------------------------------------------------------------------------------- /server/entities/Client/State/RoomOwnerState.ts: -------------------------------------------------------------------------------- 1 | import Client from ".."; 2 | import { SERVER_EVENT_NAME, CLIENT_EVENT_NAME } from "../../../../shared/constants"; 3 | import MatchingChecker from "../../ReadyChecker/MatchingChecker"; 4 | import Room from "../../Room"; 5 | import InRoomState from "./InRoomState"; 6 | 7 | class RoomOwnerState extends InRoomState { 8 | constructor(client: Client, room: Room) { 9 | super(client, room); 10 | this.startGame = this.startGame.bind(this); 11 | this.kick = this.kick.bind(this); 12 | } 13 | 14 | private startGame() { 15 | if (this.room.getSize() < 2) this.socket.emit(SERVER_EVENT_NAME.Notify, "errNotEnoughPlayer", "Danger"); 16 | else if (this.room.getMembers().some((c) => !(c.getState() instanceof InRoomState))) 17 | this.socket.emit(SERVER_EVENT_NAME.Notify, "errNotReadyInRoom", "Danger"); 18 | else new MatchingChecker(this.room.getMembers(), this.room); 19 | } 20 | 21 | private kick(id: string) { 22 | try { 23 | this.room.kick(id); 24 | } catch (e) { 25 | this.socket.emit(SERVER_EVENT_NAME.Notify, e.message, "Danger"); 26 | } 27 | } 28 | 29 | public enter(): void { 30 | super.enter(); 31 | this.socket.on(CLIENT_EVENT_NAME.FindGame, this.startGame); 32 | this.socket.on(CLIENT_EVENT_NAME.Kick, this.kick); 33 | } 34 | 35 | public exit(): void { 36 | super.exit(); 37 | this.socket.off(CLIENT_EVENT_NAME.FindGame, this.startGame); 38 | this.socket.off(CLIENT_EVENT_NAME.Kick, this.kick); 39 | } 40 | } 41 | 42 | export default RoomOwnerState; 43 | -------------------------------------------------------------------------------- /server/entities/Spell/index.ts: -------------------------------------------------------------------------------- 1 | import { SpellInfo } from "../../../shared/@types"; 2 | import { SPELL_NAME } from "../../../shared/constants"; 3 | import { generateUniqueId } from "../../../shared/utils"; 4 | import Player from "../Player"; 5 | 6 | const debuffs = [SPELL_NAME.Poison, SPELL_NAME.Punch]; 7 | 8 | abstract class Spell { 9 | protected strength = 0; 10 | public readonly id: string; 11 | 12 | constructor( 13 | public readonly name: SPELL_NAME, 14 | protected target: Player, 15 | protected caster: Player, 16 | protected duration = 0 17 | ) { 18 | this.id = generateUniqueId(); 19 | } 20 | 21 | public abstract trigger(): Promise; 22 | 23 | public getStrength(): number { 24 | return this.strength; 25 | } 26 | 27 | public getDuration(): number { 28 | return this.duration; 29 | } 30 | 31 | public getCaster(): Player { 32 | return this.caster; 33 | } 34 | 35 | public setTarget(target: Player): void { 36 | this.target = target; 37 | } 38 | 39 | public setCaster(caster: Player): void { 40 | this.caster = caster; 41 | } 42 | 43 | public setStrength(strength: number): void { 44 | this.strength = strength; 45 | } 46 | 47 | public isDebuff(): boolean { 48 | return debuffs.includes(this.name); 49 | } 50 | 51 | public toJsonData(): SpellInfo { 52 | return { 53 | id: this.id, 54 | name: this.name, 55 | strength: this.strength, 56 | duration: this.duration, 57 | target: this.target.id, 58 | }; 59 | } 60 | } 61 | 62 | export default Spell; 63 | -------------------------------------------------------------------------------- /client/views/Initiator/WhatsNew.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import ReactMarkdown from "react-markdown"; 3 | import { chosenLanguageAtom, routeAtom, versionAtom } from "../../atoms"; 4 | import Dialog from "../../components/Dialog"; 5 | import { ROUTE } from "../../constants"; 6 | import { useLocalStorage } from "../../hooks"; 7 | import useShowDialog from "../../hooks/useShowDialog"; 8 | 9 | const content = { 10 | en: { 11 | in: "in", 12 | title: "What's new", 13 | text: `### Change:\n- Poison Spell: reduce effect duration from 3 turns to 2 turns.`, 14 | }, 15 | vi: { 16 | in: "trong", 17 | title: "Có gì mới", 18 | text: `### Thay đổi:\n- Phép Độc: giảm thời gian hiệu lực của hiệu ứng từ 3 lượt xuống còn 2 lượt.`, 19 | }, 20 | }; 21 | 22 | const WhatsNew = (): JSX.Element => { 23 | const [, setRoute] = useAtom(routeAtom); 24 | const [, setClientVersion] = useLocalStorage("version", ""); 25 | const [chosenLanguage] = useAtom(chosenLanguageAtom); 26 | const [version] = useAtom(versionAtom); 27 | const [shouldShow, action] = useShowDialog(true); 28 | 29 | const close = () => { 30 | action.hide(); 31 | setClientVersion(version); 32 | setRoute(ROUTE.Hub); 33 | }; 34 | 35 | return ( 36 | 41 |
42 | {content[chosenLanguage].text} 43 |
44 |
45 | ); 46 | }; 47 | 48 | export default WhatsNew; 49 | -------------------------------------------------------------------------------- /client/views/Game/GameOverDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { memo, useEffect, useState } from "react"; 3 | import { CLIENT_EVENT_NAME, SERVER_EVENT_NAME } from "../../../shared/constants"; 4 | import { languageAtom, musicAtom, routeAtom, soundAtom } from "../../atoms"; 5 | import Dialog from "../../components/Dialog"; 6 | import { ROUTE } from "../../constants"; 7 | import socket from "../../services/socket"; 8 | 9 | const GameOverDialog = (): JSX.Element => { 10 | const [, changeRoute] = useAtom(routeAtom); 11 | const [sound] = useAtom(soundAtom); 12 | const [music] = useAtom(musicAtom); 13 | const [language] = useAtom(languageAtom); 14 | const [shouldShow, show] = useState(false); 15 | const [shouldVictory, victory] = useState(false); 16 | 17 | const backToHub = () => { 18 | socket.emit(CLIENT_EVENT_NAME.LeaveGame); 19 | music?.play(); 20 | changeRoute(ROUTE.Hub); 21 | }; 22 | 23 | useEffect(() => { 24 | socket.once(SERVER_EVENT_NAME.GameOver, (id: string) => { 25 | show(true); 26 | console.log(id, socket.id); 27 | victory(id === socket.id); 28 | id === socket.id ? sound?.play("Victory") : sound?.play("Defeat"); 29 | }); 30 | }, []); 31 | 32 | return ( 33 | 40 | {shouldVictory ?

{language.victoryMessage}

:

{language.defeatMessage}

} 41 |
42 | ); 43 | }; 44 | 45 | export default memo(GameOverDialog); 46 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/RecentPlayedCard/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { useAtom } from "jotai"; 3 | import { useState } from "react"; 4 | import { CardInfo } from "../../../../../shared/@types"; 5 | import { SERVER_EVENT_NAME, SPELL_NAME } from "../../../../../shared/constants"; 6 | import { soundAtom } from "../../../../atoms"; 7 | import Card from "../../../../components/Card"; 8 | import { useListenServerEvent } from "../../../../hooks"; 9 | import { centerizeStyle } from "../../../../styles"; 10 | import { cardPlayedKeyframes, recentPlayedCard } from "./styles"; 11 | 12 | const RecentPlayedCard = (): JSX.Element => { 13 | const [sound] = useAtom(soundAtom); 14 | const [shouldAnimate, animate] = useState(false); 15 | const [card, setCard] = useState({ 16 | id: "", 17 | power: 0, 18 | spell: SPELL_NAME.Void, 19 | }); 20 | 21 | //const stopAnimation = () => setCard(undefined); 22 | 23 | useListenServerEvent(SERVER_EVENT_NAME.CardPlayed, (card: CardInfo) => { 24 | setCard(card); 25 | animate(true); 26 | sound?.play("PlayCard"); 27 | }); 28 | 29 | useListenServerEvent(SERVER_EVENT_NAME.NewTurn, () => animate(false)); 30 | 31 | return ( 32 | <> 33 | {shouldAnimate && ( 34 |
42 | 43 |
44 | )} 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default RecentPlayedCard; 51 | -------------------------------------------------------------------------------- /client/components/IntegrateInput/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { ReactNode, useRef } from "react"; 3 | import { StyleVariation } from "../../../shared/@types"; 4 | import { InnerButton, InnerInput, StyledIntegratedInput } from "./styles"; 5 | 6 | type IntegrateInputProps = { 7 | minLength?: number; 8 | maxLength?: number; 9 | variation?: StyleVariation; 10 | children?: ReactNode; 11 | onClick?: (value?: string) => void; 12 | disabled?: boolean; 13 | defaultValue?: string; 14 | placeholder?: string; 15 | }; 16 | 17 | const dummyFn = () => { 18 | return; 19 | }; 20 | 21 | const IntegrateInput = ({ 22 | disabled = false, 23 | placeholder = "", 24 | defaultValue = "", 25 | variation = "Primary", 26 | children = "Enter", 27 | onClick = dummyFn, 28 | ...props 29 | }: IntegrateInputProps): JSX.Element => { 30 | const button = useRef(null); 31 | const input = useRef(null); 32 | 33 | const submit = () => onClick(input.current?.value); 34 | 35 | return ( 36 | 37 | 50 | {!disabled && ( 51 | 52 | {children} 53 | 54 | )} 55 | 56 | ); 57 | }; 58 | 59 | export default IntegrateInput; 60 | -------------------------------------------------------------------------------- /client/views/Notifications/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { useEffect } from "react"; 3 | import { createPortal } from "react-dom"; 4 | import { animated, useTransition } from "react-spring"; 5 | import { StyleVariation } from "../../../shared/@types"; 6 | import { SERVER_EVENT_NAME } from "../../../shared/constants"; 7 | import { languageAtom, notificationsAtom } from "../../atoms"; 8 | import { useNotify } from "../../hooks/useNotify"; 9 | import socket from "../../services/socket"; 10 | import { notificationStyle } from "./styles"; 11 | 12 | const Notifications = (): JSX.Element => { 13 | const [notifications] = useAtom(notificationsAtom); 14 | const [language] = useAtom(languageAtom); 15 | const notify = useNotify(); 16 | 17 | const transitions = useTransition(notifications, (n) => n.id, { 18 | from: { transform: "translate(-50%, 0%)", opacity: 0 }, 19 | leave: { transform: "translate(-50%, 0%)", opacity: 0 }, 20 | enter: { transform: "translate(-50%, -100%)", opacity: 1 }, 21 | }); 22 | 23 | useEffect(() => { 24 | const onNotify = (message: string, variation: StyleVariation) => notify(language[message], variation); 25 | 26 | socket.on(SERVER_EVENT_NAME.Notify, onNotify); 27 | 28 | return () => void socket.off(SERVER_EVENT_NAME.Notify, onNotify); 29 | }, [notify]); 30 | 31 | return createPortal( 32 | transitions.map(({ item, props, key }, i, arr) => ( 33 | 34 | {item.message} 35 | 36 | )), 37 | document.getElementById("notification") as HTMLDivElement 38 | ); 39 | }; 40 | 41 | export default Notifications; 42 | -------------------------------------------------------------------------------- /client/HOCs/withSpriteLoading.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { JSXElementConstructor, useEffect, useRef, useState } from "react"; 3 | import { routeAtom } from "../atoms"; 4 | import Loading from "../components/Loading"; 5 | import Page from "../components/Page"; 6 | import { ROUTE } from "../constants"; 7 | import { useNotify } from "../hooks/useNotify"; 8 | import { centerizeStyle } from "../styles"; 9 | 10 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 11 | function withSpriteLoading(WrappedComponent: JSXElementConstructor

& C, paths: string[]) { 12 | type Props = JSX.LibraryManagedAttributes; 13 | 14 | const WithSpriteLoading = (props: Props) => { 15 | const [, changeRoute] = useAtom(routeAtom); 16 | const [status, setStatus] = useState<"Loading" | "Loaded" | "Failed">("Loading"); 17 | const [loaded, setLoaded] = useState(0); 18 | const total = useRef(paths.length); 19 | const notify = useNotify(); 20 | 21 | useEffect(() => { 22 | if (loaded === total.current) setStatus("Loaded"); 23 | else { 24 | const img = new Image(); 25 | img.src = paths[loaded]; 26 | img.onload = () => setLoaded(loaded + 1); 27 | img.onerror = () => { 28 | notify("Failed to load resources!", "Danger"); 29 | changeRoute(ROUTE.Hub); 30 | }; 31 | } 32 | }, [loaded]); 33 | 34 | return status === "Loaded" ? ( 35 | 36 | ) : ( 37 | 38 | {" "} 39 | 40 | ); 41 | }; 42 | 43 | return WithSpriteLoading; 44 | } 45 | 46 | export default withSpriteLoading; 47 | -------------------------------------------------------------------------------- /client/views/Hub/PlayerNameInput.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { GameMatchingStatus } from "../../../shared/@types"; 4 | import { CLIENT_EVENT_NAME, SERVER_EVENT_NAME } from "../../../shared/constants"; 5 | import { languageAtom, roomAtom } from "../../atoms"; 6 | import IntegrateInput from "../../components/IntegrateInput"; 7 | import { useListenServerEvent, useLocalStorage } from "../../hooks"; 8 | import { useNotify } from "../../hooks/useNotify"; 9 | import socket from "../../services/socket"; 10 | 11 | const PlayerNameInput = (): JSX.Element => { 12 | const notify = useNotify(); 13 | const [name, setName] = useLocalStorage("name", "player"); 14 | const [isDisabled, disable] = useState(false); 15 | const [language] = useAtom(languageAtom); 16 | const [room] = useAtom(roomAtom); 17 | 18 | const changeName = useCallback((value = "") => { 19 | if (value) { 20 | socket.emit(CLIENT_EVENT_NAME.Rename, value); 21 | setName(value); 22 | } else notify("errNameLength", "Danger"); 23 | }, []); 24 | 25 | useEffect(() => void changeName(name), []); 26 | 27 | useEffect(() => { 28 | if (room) disable(true); 29 | else disable(false); 30 | }, [room]); 31 | 32 | useListenServerEvent(SERVER_EVENT_NAME.UpdateGameMatchingStatus, (status: GameMatchingStatus) => 33 | disable(status !== "Canceled") 34 | ); 35 | 36 | return ( 37 | 44 | {language.change} 45 | 46 | ); 47 | }; 48 | 49 | export default PlayerNameInput; 50 | -------------------------------------------------------------------------------- /client/components/Sprite/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { memo, useEffect, useState } from "react"; 3 | import { SpriteProps } from "../../types"; 4 | import { spriteAnimation, StyledSprite } from "./styles"; 5 | 6 | const Sprite = ({ 7 | size: [width, height], 8 | src, 9 | onReachFrame, 10 | scale = 1, 11 | row = 0, 12 | fps = 12, 13 | steps = 1, 14 | loop = 1, 15 | stop = false, 16 | children, 17 | ...props 18 | }: SpriteProps): JSX.Element => { 19 | const [naturalWidth, setNaturalWidth] = useState(0); 20 | 21 | useEffect(() => { 22 | const img = new Image(); 23 | img.src = src; 24 | img.onload = () => setNaturalWidth(img.naturalWidth); 25 | }, []); 26 | 27 | useEffect(() => { 28 | if (!stop && onReachFrame) { 29 | const timeouts: number[] = []; 30 | const frameTime = (1 / fps) * 1000; 31 | 32 | for (let i = 0; i < steps; i++) timeouts.push(window.setTimeout(() => onReachFrame(i + 1), frameTime * i)); 33 | 34 | return () => timeouts.forEach((t) => clearTimeout(t)); 35 | } 36 | }, [stop]); 37 | 38 | return ( 39 | 0 ? loop : 0}; 52 | `, 53 | ]} 54 | {...props} 55 | > 56 | {children} 57 | 58 | ); 59 | }; 60 | 61 | export default memo(Sprite); 62 | -------------------------------------------------------------------------------- /client/views/Hub/Header/Tutorial/SpellDictionary/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { SPELL_NAME } from "../../../../../../shared/constants"; 3 | import { languageAtom } from "../../../../../atoms"; 4 | import Grid from "../../../../../components/Grid"; 5 | import H1 from "../../../../../components/H1"; 6 | import Icon from "../../../../../components/Icon"; 7 | import { SpellDescription } from "./styles"; 8 | 9 | const SpellDictionary = (): JSX.Element => { 10 | const [language] = useAtom(languageAtom); 11 | 12 | return ( 13 |

14 |

{language.spellDictionary}

15 | 16 | 17 |
18 | 19 | 20 |
21 |
{language.noEffectDescription}
22 |
23 | 24 | 25 |
{language.punchDescription}
26 |
27 | 28 | 29 |
{language.poisonDescription}
30 |
31 | 32 | 33 |
{language.healDescription}
34 |
35 | 36 | 37 |
{language.shieldDescription}
38 |
39 | 40 | 41 |
{language.mirrorDescription}
42 |
43 |
44 |
45 | ); 46 | }; 47 | 48 | export default SpellDictionary; 49 | -------------------------------------------------------------------------------- /client/components/Dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { ReactNode } from "react"; 3 | import { createPortal } from "react-dom"; 4 | import { StyleVariation } from "../../../shared/@types"; 5 | import { soundAtom } from "../../atoms"; 6 | import { COLOR } from "../../constants"; 7 | import { shadeColor } from "../../utils"; 8 | import Button from "../Button"; 9 | import { DialogContent, DialogFooter, DialogHeader, StyledDialog } from "./styles"; 10 | 11 | type DialogProps = { 12 | variation?: StyleVariation; 13 | title?: string; 14 | children?: ReactNode; 15 | show?: boolean; 16 | onYes?: () => void; 17 | yesMessage?: string; 18 | noFooter?: boolean; 19 | }; 20 | 21 | function Dialog({ 22 | show = false, 23 | variation = "Normal", 24 | title, 25 | children, 26 | noFooter, 27 | yesMessage = "OK", 28 | onYes, 29 | noMessage, 30 | onNo, 31 | }: DialogProps & { noMessage?: string; onNo?: () => void }): JSX.Element { 32 | const [sound] = useAtom(soundAtom); 33 | 34 | const onAccept = () => { 35 | sound?.play("Accept"); 36 | if (onYes) onYes(); 37 | }; 38 | 39 | const onCancel = () => { 40 | sound?.play("Cancel"); 41 | if (onNo) onNo(); 42 | }; 43 | 44 | return createPortal( 45 | 46 | 47 | {title && {title}} 48 | {children} 49 | {!noFooter && ( 50 | 51 | 54 | {noMessage && ( 55 | 58 | )} 59 | 60 | )} 61 | 62 | , 63 | document.getElementById("dialog") as HTMLDivElement 64 | ); 65 | } 66 | 67 | export default Dialog; 68 | -------------------------------------------------------------------------------- /client/views/Hub/GameConfirmDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { useEffect, useState } from "react"; 3 | import { GameMatchingStatus } from "../../../shared/@types"; 4 | import { CLIENT_EVENT_NAME, SERVER_EVENT_NAME } from "../../../shared/constants"; 5 | import { languageAtom, soundAtom } from "../../atoms"; 6 | import Dialog from "../../components/Dialog"; 7 | import Loading from "../../components/Loading"; 8 | import { useListenServerEvent } from "../../hooks"; 9 | import socket from "../../services/socket"; 10 | 11 | type ConfirmStatus = "pending" | "accepted" | "rejected"; 12 | 13 | const GameConfirmDialog = (): JSX.Element => { 14 | const [sound] = useAtom(soundAtom); 15 | const [language] = useAtom(languageAtom); 16 | const [shouldShow, show] = useState(false); 17 | const [confirm, setConfirm] = useState("pending"); 18 | 19 | const onConfirm = (isAccepted: boolean) => { 20 | socket.emit(CLIENT_EVENT_NAME.ReadyConfirm, isAccepted); 21 | 22 | if (isAccepted) setConfirm("accepted"); 23 | else setConfirm("rejected"); 24 | }; 25 | 26 | const onYes = () => onConfirm(true); 27 | const onNo = () => onConfirm(false); 28 | 29 | useEffect(() => { 30 | if (!shouldShow) setConfirm("pending"); 31 | else sound?.play("GameFound"); 32 | }, [shouldShow]); 33 | 34 | useListenServerEvent(SERVER_EVENT_NAME.UpdateGameMatchingStatus, (status: GameMatchingStatus) => 35 | show(status === "Found") 36 | ); 37 | 38 | return ( 39 | 49 | {confirm !== "pending" ? :

{language.gameFoundMessage}

} 50 |
51 | ); 52 | }; 53 | 54 | export default GameConfirmDialog; 55 | -------------------------------------------------------------------------------- /client/components/Charger/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | import { chargeNodeStyle, emptyNodeStyle, StyledChargePointBar } from "./styles"; 3 | import { ChargePointBarState } from "./types"; 4 | 5 | type ChargerProps = { 6 | point?: number; 7 | max: number; 8 | className?: string; 9 | onAnimationEnd?: () => void; 10 | }; 11 | 12 | const charge = (value: number, max: number): boolean[] => { 13 | const arr: boolean[] = []; 14 | for (let i = 0; i < max; i++) arr.push(i < value); 15 | return arr; 16 | }; 17 | 18 | const Charger = ({ point = 0, max, ...props }: ChargerProps): JSX.Element => { 19 | const [nodes, setNodeStatus] = useState(charge(point, max)); 20 | const [barState, setBarState] = useState("Safe"); 21 | const prevNodes = useRef(nodes); 22 | 23 | const updateBarState = useCallback(() => { 24 | if (point < max * 0.5) setBarState("Safe"); 25 | else if (point < max * 0.8) setBarState("Warning"); 26 | else setBarState("Danger"); 27 | }, [point]); 28 | 29 | const renderNodes = () => 30 | nodes.map((isCharged, i, arr) => { 31 | const isIncreased = point >= prevNodes.current.filter(Boolean).length; 32 | // Triggered on the final node in the transition chain 33 | const onTransitionEnd = 34 | (i === 0 && point === 0) || (isIncreased && i === point - 1) || i === point + 1 ? updateBarState : undefined; 35 | 36 | const delay = isCharged === prevNodes.current[i] ? 0 : 0.1 * (isIncreased ? i : arr.length - 1 - i); 37 | 38 | return ( 39 |
44 | ); 45 | }); 46 | 47 | useEffect(() => setNodeStatus(charge(point, max)), [point]); 48 | useEffect(() => void (prevNodes.current = nodes), [nodes]); 49 | 50 | return {renderNodes()}; 51 | }; 52 | 53 | export default Charger; 54 | -------------------------------------------------------------------------------- /client/components/Card/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import styled from "@emotion/styled"; 3 | import ContentFrameSprite from "url:../../assets/sprites/card_content_frame.png"; 4 | import { COLOR } from "../../constants"; 5 | import { BorderColors, centerizeStyle, pixelBorderStyle } from "../../styles"; 6 | import { shadeColor, tintColor } from "../../utils"; 7 | import CenterizedGrid from "../CenterizedGrid"; 8 | 9 | const CONTENT_BORDER_WIDTH = 12; 10 | 11 | const commonStyle = css` 12 | ${pixelBorderStyle(2, [COLOR.Normal], [ 13 | COLOR.White, 14 | ...new Array(3).fill(shadeColor(COLOR.Primary, 20)), 15 | ] as BorderColors)}; 16 | position: relative; 17 | box-sizing: border-box; 18 | background-color: ${COLOR.Primary}; 19 | user-select: none; 20 | color: ${tintColor(COLOR.Normal, 20)}; 21 | font-family: "Dogica Pixel"; 22 | font-weight: bold; 23 | transition: transform 0.3s; 24 | `; 25 | 26 | export const cardChosenStyle = css` 27 | transform: translateY(-30px); 28 | `; 29 | 30 | export const SmallCard = styled(CenterizedGrid)` 31 | ${commonStyle}; 32 | font-size: 10px; 33 | width: 32px; 34 | height: 48px; 35 | padding: 2px; 36 | `; 37 | 38 | export const NormalCard = styled.div` 39 | ${commonStyle}; 40 | width: 48px; 41 | height: 72px; 42 | `; 43 | 44 | export const CardContent = styled.div` 45 | ${centerizeStyle}; 46 | border: ${CONTENT_BORDER_WIDTH}px solid transparent; 47 | border-image: url(${ContentFrameSprite}) ${CONTENT_BORDER_WIDTH} round; 48 | width: 12px; 49 | height: 36px; 50 | `; 51 | 52 | const cardInfoStyle = css` 53 | position: absolute; 54 | display: inline-block; 55 | background-color: ${COLOR.Primary}; 56 | left: 50%; 57 | `; 58 | 59 | export const CardPower = styled.div` 60 | ${cardInfoStyle}; 61 | font-size: 10px; 62 | top: ${-CONTENT_BORDER_WIDTH}px; 63 | transform: translate(-50%, -25%); 64 | `; 65 | 66 | export const CardAction = styled.div` 67 | ${cardInfoStyle}; 68 | font-size: 14px; 69 | font-weight: bold; 70 | bottom: ${-CONTENT_BORDER_WIDTH - 1}px; 71 | transform: translate(-50%, 25%); 72 | `; 73 | -------------------------------------------------------------------------------- /server/entities/Client/State/IdleState.ts: -------------------------------------------------------------------------------- 1 | import ClientState from "."; 2 | import Client from ".."; 3 | import { CLIENT_EVENT_NAME, SERVER_EVENT_NAME } from "../../../../shared/constants"; 4 | import Room from "../../Room"; 5 | import Server from "../../Server"; 6 | 7 | class IdleState extends ClientState { 8 | private findGame: () => void; 9 | 10 | constructor(client: Client) { 11 | super(client); 12 | 13 | this.createRoom = this.createRoom.bind(this); 14 | this.joinRoom = this.joinRoom.bind(this); 15 | this.findGame = Server.getInstance().enqueueClient.bind(Server.getInstance(), this.client); 16 | this.setName = this.setName.bind(this); 17 | } 18 | 19 | private createRoom() { 20 | const room = new Room(this.client); 21 | Server.getInstance().addRoom(room); 22 | } 23 | 24 | private joinRoom(id: string) { 25 | const room = Server.getInstance().getRoom(id); 26 | 27 | if (room) { 28 | try { 29 | room.add(this.client); 30 | } catch (e) { 31 | this.socket.emit(SERVER_EVENT_NAME.Notify, e.message, "Danger"); 32 | } 33 | } else this.socket.emit(SERVER_EVENT_NAME.Notify, "errRoomNotFound", "Danger"); 34 | } 35 | 36 | private setName(name: string) { 37 | if (name.length < 2 || name.length > 24) this.socket.emit(SERVER_EVENT_NAME.Notify, "errNameLength", "Danger"); 38 | else { 39 | this.client.name = name; 40 | this.socket.emit(SERVER_EVENT_NAME.Notify, "notiNameChanged", "Safe"); 41 | } 42 | } 43 | 44 | public enter(): void { 45 | this.socket.on(CLIENT_EVENT_NAME.CreateRoom, this.createRoom); 46 | this.socket.on(CLIENT_EVENT_NAME.JoinRoom, this.joinRoom); 47 | this.socket.on(CLIENT_EVENT_NAME.FindGame, this.findGame); 48 | this.socket.on(CLIENT_EVENT_NAME.Rename, this.setName); 49 | } 50 | 51 | public exit(): void { 52 | this.socket.off(CLIENT_EVENT_NAME.CreateRoom, this.createRoom); 53 | this.socket.off(CLIENT_EVENT_NAME.JoinRoom, this.joinRoom); 54 | this.socket.off(CLIENT_EVENT_NAME.FindGame, this.findGame); 55 | this.socket.off(CLIENT_EVENT_NAME.Rename, this.setName); 56 | } 57 | } 58 | 59 | export default IdleState; 60 | -------------------------------------------------------------------------------- /client/views/Hub/Header/Tutorial/GameplayTutorial.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import React, { useEffect, useState } from "react"; 3 | import ReactMarkdown from "react-markdown"; 4 | import { SPELL_NAME } from "../../../../../shared/constants"; 5 | import { languageAtom } from "../../../../atoms"; 6 | import Card from "../../../../components/Card"; 7 | import Charger from "../../../../components/Charger"; 8 | import H1 from "../../../../components/H1"; 9 | import Sprite from "../../../../components/Sprite"; 10 | import { xCenterStyle } from "../../../../styles"; 11 | import BoxOfCardsSprite from "url:../../../../assets/sprites/box_of_cards.png"; 12 | 13 | const GameplayTutorial = (): JSX.Element => { 14 | const [point, setPoint] = useState(0); 15 | const [language] = useAtom(languageAtom); 16 | 17 | useEffect(() => { 18 | const timeout = window.setTimeout(() => setPoint((point + 1) % 11), 1200); 19 | return () => clearTimeout(timeout); 20 | }, [point]); 21 | 22 | return ( 23 |
24 |

Gameplay

25 | {language.gameplayStarting} 26 | 30 | {language.gameplayCard} 31 | 36 | {language.gameplayBox} 37 |
47 | 48 | 49 |
50 | {language.gameplaySpell} 51 |
52 | ); 53 | }; 54 | 55 | export default GameplayTutorial; 56 | -------------------------------------------------------------------------------- /server/entities/ReadyChecker/index.ts: -------------------------------------------------------------------------------- 1 | import { SERVER_EVENT_NAME } from "../../../shared/constants"; 2 | import Client from "../Client"; 3 | import FindingState from "../Client/State/FindingState"; 4 | import IdleState from "../Client/State/IdleState"; 5 | import ReadyCheckState from "../Client/State/ReadyCheckState"; 6 | import Room from "../Room"; 7 | import Server from "../Server"; 8 | 9 | const DEFAULT_WAIT_TIME = 15000; 10 | 11 | abstract class ReadyChecker { 12 | private passes: Client[] = []; 13 | private failures: Client[] = []; 14 | private timeout: NodeJS.Timeout; 15 | 16 | constructor(protected clients: Client[], protected room?: Room) { 17 | if (this.room) this.room.isInGame = true; 18 | this.clients.forEach((c) => c.changeState(new ReadyCheckState(c, this))); 19 | this.timeout = setTimeout(this.onUnqualified.bind(this), DEFAULT_WAIT_TIME); 20 | } 21 | 22 | private onResponse() { 23 | if (this.passes.length === this.clients.length) { 24 | clearTimeout(this.timeout); 25 | this.onQualify(); 26 | } else if (this.passes.length + this.failures.length === this.clients.length) { 27 | clearTimeout(this.timeout); 28 | this.onUnqualified(); 29 | } 30 | } 31 | 32 | private onUnqualified(): void { 33 | if (this.room) this.room.isInGame = false; 34 | this.clients.forEach((c) => { 35 | c.getSocket().emit(SERVER_EVENT_NAME.UpdateGameMatchingStatus, "Canceled"); 36 | c.getSocket().emit(SERVER_EVENT_NAME.Notify, "notiFailMatch", "Warning"); 37 | 38 | if (this.room) this.room.back(c); 39 | else if (this.passes.includes(c)) { 40 | Server.getInstance().enqueueClient(c); 41 | c.changeState(new FindingState(c)); 42 | } else c.changeState(new IdleState(c)); 43 | }); 44 | } 45 | 46 | protected abstract onQualify(): void; 47 | 48 | public ready(client: Client): void { 49 | this.passes.push(client); 50 | this.onResponse(); 51 | } 52 | 53 | public fail(client: Client): void { 54 | this.passes = this.passes.filter((c) => c !== client); 55 | this.failures.push(client); 56 | this.onResponse(); 57 | } 58 | } 59 | 60 | export default ReadyChecker; 61 | -------------------------------------------------------------------------------- /client/views/Game/GameBoard/SpellAction/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import produce from "immer"; 3 | import { useAtom } from "jotai"; 4 | import { useEffect, useState } from "react"; 5 | import { PassiveActionInfo } from "../../../../../shared/@types"; 6 | import { SERVER_EVENT_NAME } from "../../../../../shared/constants"; 7 | import { languageAtom } from "../../../../atoms"; 8 | import { useListenServerEvent } from "../../../../hooks"; 9 | import SpellIndicator from "../../Status/Spells/SpellIndicator"; 10 | import { StyledSpellAction } from "./styled"; 11 | 12 | const SpellAction = (): JSX.Element => { 13 | const [actions, setActions] = useState([]); 14 | const [shouldShow, show] = useState(false); 15 | const [language] = useAtom(languageAtom); 16 | 17 | useListenServerEvent(SERVER_EVENT_NAME.ActivatePassive, (passive: PassiveActionInfo) => { 18 | setActions((list) => 19 | produce(list, (draft) => { 20 | if (draft.length >= 3) draft.shift(); 21 | draft.push(passive); 22 | }) 23 | ); 24 | show(true); 25 | }); 26 | 27 | useEffect(() => { 28 | if (shouldShow) { 29 | const timeout = window.setTimeout(() => show(false), 5000); 30 | return () => clearTimeout(timeout); 31 | } 32 | }, [shouldShow]); 33 | 34 | return ( 35 | { 40 | if (!shouldShow) setActions([]); 41 | }} 42 | > 43 | {actions.map((a) => ( 44 |
45 | {" "} 52 | {language.of} {a.attacker.name} {language[a.message]}{" "} 53 | {" "} 60 | {language.of} {a.defender.name} 61 |
62 | ))} 63 |
64 | ); 65 | }; 66 | 67 | export default SpellAction; 68 | -------------------------------------------------------------------------------- /client/views/App.tsx: -------------------------------------------------------------------------------- 1 | import produce from "immer"; 2 | import { useAtom } from "jotai"; 3 | import { lazy, Suspense } from "react"; 4 | import { ClientInfo, GameMatchingStatus, RoomInfo } from "../../shared/@types"; 5 | import { SERVER_EVENT_NAME } from "../../shared/constants"; 6 | import { roomAtom, routeAtom } from "../atoms"; 7 | import Loading from "../components/Loading"; 8 | import Page from "../components/Page"; 9 | import { ROUTE } from "../constants"; 10 | import { useListenServerEvent } from "../hooks"; 11 | import { centerizeStyle } from "../styles"; 12 | import ReconnectDialog from "./ReconnectDialog"; 13 | //import Test from "./Test"; 14 | 15 | const Game = lazy(() => import("./Game")); 16 | const Hub = lazy(() => import("./Hub")); 17 | const Initiator = lazy(() => import("./Initiator")); 18 | 19 | const App = (): JSX.Element => { 20 | const [route, changeRoute] = useAtom(routeAtom); 21 | const [, setRoom] = useAtom(roomAtom); 22 | 23 | useListenServerEvent(SERVER_EVENT_NAME.GetRoomInfo, (info: RoomInfo) => setRoom(info)); 24 | useListenServerEvent(SERVER_EVENT_NAME.LeftRoom, () => setRoom(null)); 25 | 26 | useListenServerEvent(SERVER_EVENT_NAME.UpdateGameMatchingStatus, (status: GameMatchingStatus) => { 27 | if (status === "Canceled") changeRoute(ROUTE.Hub); 28 | }); 29 | 30 | useListenServerEvent(SERVER_EVENT_NAME.FriendJoined, (friend: ClientInfo) => 31 | setRoom((room) => 32 | produce(room, (draft) => { 33 | draft?.members.push(friend); 34 | }) 35 | ) 36 | ); 37 | 38 | useListenServerEvent(SERVER_EVENT_NAME.FriendLeft, (id: string, owner: string) => 39 | setRoom((room) => 40 | produce(room, (draft) => { 41 | if (draft) { 42 | draft.members = draft?.members.filter((m) => m.id !== id); 43 | draft.owner = owner; 44 | } 45 | }) 46 | ) 47 | ); 48 | 49 | return ( 50 | <> 51 | 54 | {" "} 55 | 56 | } 57 | > 58 | {route === ROUTE.Init && } 59 | {route === ROUTE.Hub && } 60 | {route === ROUTE.InGame && } 61 | {route === ROUTE.Test &&
} 62 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default App; 69 | -------------------------------------------------------------------------------- /client/views/Game/Player/Hand/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { memo, useCallback, useState } from "react"; 3 | import { animated, useTransition } from "react-spring"; 4 | import { CardInfo } from "../../../../../shared/@types"; 5 | import { CLIENT_EVENT_NAME, SERVER_EVENT_NAME } from "../../../../../shared/constants"; 6 | import { languageAtom } from "../../../../atoms"; 7 | import Card from "../../../../components/Card"; 8 | import { useInTurn, useListenServerEvent, useOnClickOutside, useOnEliminate } from "../../../../hooks"; 9 | import { useNotify } from "../../../../hooks/useNotify"; 10 | import socket from "../../../../services/socket"; 11 | import { fadeOut } from "../../../../styles"; 12 | import { StyledHand } from "./styles"; 13 | 14 | const Hand = (): JSX.Element => { 15 | const [language] = useAtom(languageAtom); 16 | const isInTurn = useInTurn(socket.id); 17 | const isEliminated = useOnEliminate(socket.id); 18 | const notify = useNotify(); 19 | const [cards, setCards] = useState([]); 20 | const [chosenCard, setChosenCard] = useState(""); 21 | const ref = useOnClickOutside(() => setChosenCard("")); 22 | 23 | const transitions = useTransition(cards, (c) => c.id, { 24 | from: { 25 | opacity: 0, 26 | transform: "translateY(40px)", 27 | maxWidth: "0px", 28 | }, 29 | enter: [{ maxWidth: "100px" }, { opacity: 1, transform: "translateY(0px)" }], 30 | leave: fadeOut, 31 | }); 32 | 33 | const playCard = useCallback( 34 | (id: string) => { 35 | if (chosenCard !== id) setChosenCard(id); 36 | else if (isInTurn) { 37 | socket.emit(CLIENT_EVENT_NAME.PlayCard, chosenCard); 38 | setCards((list) => list.filter((c) => c.id !== chosenCard)); 39 | } else notify(language.errNotInTurn, "Danger"); 40 | }, 41 | [isInTurn, chosenCard] 42 | ); 43 | 44 | useListenServerEvent(SERVER_EVENT_NAME.GetCards, (cards: CardInfo[]) => setCards((list) => [...list, ...cards])); 45 | 46 | return ( 47 | 48 | {transitions.map(({ item, props, key }) => ( 49 | 50 | 51 | 52 | ))} 53 | 54 | ); 55 | }; 56 | 57 | export default memo(Hand); 58 | -------------------------------------------------------------------------------- /server/entities/Server/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { createServer } from "http"; 3 | import { Server as SocketServer } from "socket.io"; 4 | import Client from "../Client"; 5 | import FindingState from "../Client/State/FindingState"; 6 | import IdleState from "../Client/State/IdleState"; 7 | import Room from "../Room"; 8 | import GameMatcher from "./GameMatcher"; 9 | import path from "path"; 10 | 11 | class Server { 12 | public static port = 3000; 13 | private static instance: Server; 14 | private rooms: Room[] = []; 15 | private gameMatcher = new GameMatcher(); 16 | 17 | private constructor() { 18 | const app = express(); 19 | const httpServer = createServer(app); 20 | const socketServer = new SocketServer(httpServer, { 21 | cors: { 22 | origin: "*", 23 | methods: ["GET", "POST"], 24 | credentials: true, 25 | }, 26 | }); 27 | 28 | app.use(express.static(path.join(__dirname, "..", "..", "..", "dist"))); 29 | app.get("/", (_, res) => res.sendFile(path.join(__dirname, "..", "..", "..", "build", "index.html"))); 30 | 31 | socketServer.on("connect", (socket) => { 32 | new Client(socket); 33 | console.log("Client connected: " + socket.id); 34 | socket.on("disconnect", () => console.log("Client left: " + socket.id)); 35 | }); 36 | 37 | httpServer.listen(Server.port, () => console.info("Server started on port " + Server.port)); 38 | } 39 | 40 | public static getInstance(): Server { 41 | if (!Server.instance) Server.instance = new Server(); 42 | return Server.instance; 43 | } 44 | 45 | public enqueueClient(client: Client): void { 46 | client.changeState(new FindingState(client)); 47 | this.gameMatcher.add(client); 48 | } 49 | 50 | public dequeueClient(client: Client): void { 51 | this.gameMatcher.remove(client); 52 | client.changeState(new IdleState(client)); 53 | } 54 | 55 | public addRoom(room: Room): void { 56 | this.rooms.push(room); 57 | } 58 | 59 | public removeRoom(room: Room): void { 60 | this.rooms = this.rooms.filter((r) => r !== room); 61 | } 62 | 63 | public getRoom(id: string): Room | undefined { 64 | return this.rooms.find((r) => r.id === id); 65 | } 66 | 67 | public getRoomHasClient(client: Client): Room | undefined { 68 | return this.rooms.find((r) => r.getMembers().includes(client)); 69 | } 70 | } 71 | 72 | export default Server; 73 | -------------------------------------------------------------------------------- /server/entities/Player/SpellManager.ts: -------------------------------------------------------------------------------- 1 | import Player from "."; 2 | import { EventsFromServer } from "../../../shared/@types"; 3 | import { SERVER_EVENT_NAME } from "../../../shared/constants"; 4 | import { waitFor } from "../../utilities"; 5 | import Spell from "../Spell"; 6 | import PassiveSpell from "../Spell/PassiveSpell"; 7 | 8 | class SpellManager { 9 | private debuffs: Spell[] = []; 10 | private talismans: PassiveSpell[] = []; 11 | 12 | constructor( 13 | private player: Player, 14 | private broadcast: (ev: SERVER_EVENT_NAME, ...data: Parameters) => void 15 | ) {} 16 | 17 | private addTalisman(talisman: PassiveSpell): void { 18 | this.talismans.push(talisman); 19 | this.player.socket.emit(SERVER_EVENT_NAME.TakeSpell, talisman.toJsonData()); 20 | } 21 | 22 | private async activateTalisman(spell: Spell): Promise { 23 | if (this.talismans.length > 0 && spell.isDebuff()) { 24 | const talisman = this.talismans[0]; 25 | this.talismans = this.talismans.filter((t) => t !== talisman); 26 | 27 | for await (const info of talisman.activate(spell)) { 28 | this.broadcast(SERVER_EVENT_NAME.ActivatePassive, info); 29 | await waitFor(1000); 30 | } 31 | 32 | return true; 33 | } 34 | 35 | return false; 36 | } 37 | 38 | public purify(): void { 39 | const removedDebuffs = this.debuffs.map((d) => d.id); 40 | this.debuffs = []; 41 | this.broadcast(SERVER_EVENT_NAME.Purify, { 42 | target: this.player.id, 43 | remove: removedDebuffs, 44 | }); 45 | } 46 | 47 | public async triggerPendingDebuffs(): Promise { 48 | for (const debuff of this.debuffs) { 49 | await debuff.trigger(); 50 | if (debuff.getDuration() === 0) this.debuffs = this.debuffs.filter((d) => d !== debuff); 51 | this.broadcast(SERVER_EVENT_NAME.TakeSpell, debuff.toJsonData()); 52 | await waitFor(1000); 53 | } 54 | } 55 | 56 | public async takeSpell(spell: Spell): Promise { 57 | if (spell.getStrength() > 0 && !(await this.activateTalisman(spell))) { 58 | if (spell.getDuration() === 0) await spell.trigger(); 59 | else if (spell.isDebuff()) this.debuffs.push(spell); 60 | else return this.addTalisman(spell as PassiveSpell); 61 | 62 | this.broadcast(SERVER_EVENT_NAME.TakeSpell, spell.toJsonData()); 63 | await waitFor(1000); 64 | } 65 | } 66 | } 67 | 68 | export default SpellManager; 69 | -------------------------------------------------------------------------------- /client/views/Hub/Room/Members/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { CLIENT_EVENT_NAME } from "../../../../../shared/constants"; 3 | import { languageAtom, roomAtom } from "../../../../atoms"; 4 | import Button from "../../../../components/Button"; 5 | import Dialog from "../../../../components/Dialog"; 6 | import DropDown from "../../../../components/Dropdown"; 7 | import EmptySlot from "../../../../components/EmptySlot"; 8 | import useShowDialog from "../../../../hooks/useShowDialog"; 9 | import socket from "../../../../services/socket"; 10 | import Member from "./Member"; 11 | import { StyledMembers } from "./styles"; 12 | 13 | const Members = (): JSX.Element => { 14 | const [room] = useAtom(roomAtom); 15 | const [language] = useAtom(languageAtom); 16 | const [shouldDialogShow, dialogAction] = useShowDialog(); 17 | 18 | const generateList = () => { 19 | const arr: JSX.Element[] = []; 20 | 21 | for (let i = 0; i < 4; i++) { 22 | const member = room?.members[i]; 23 | if (member) { 24 | const component = ; 25 | 26 | const kick = () => { 27 | socket.emit(CLIENT_EVENT_NAME.Kick, member.id); 28 | dialogAction.hide(); 29 | }; 30 | 31 | arr.push( 32 | socket.id === room?.owner && member.id !== socket.id ? ( 33 | <> 34 | 35 | 38 | 39 | 40 | 50 |

51 | {language.kick} {member.name}? {language.roomKickMessage} 52 |

53 |
54 | 55 | ) : ( 56 | component 57 | ) 58 | ); 59 | } else arr.push(); 60 | } 61 | 62 | return arr; 63 | }; 64 | 65 | return {generateList()}; 66 | }; 67 | 68 | export default Members; 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weird-box", 3 | "version": "0.1.0", 4 | "description": "", 5 | "frontend": "./dist/index.html", 6 | "targets": { 7 | "frontend": { 8 | "engines": { 9 | "browsers": "> 0.25%" 10 | } 11 | } 12 | }, 13 | "engines": { 14 | "node": "14.17.0" 15 | }, 16 | "scripts": { 17 | "dev-client": "if [ -d ./dist ]; then rm -r ./dist; fi && parcel ./client/index.html --target=frontend", 18 | "dev-server": "ts-node-dev --respawn --project ./node.tsconfig.json ./server/index.ts", 19 | "postinstall": "if [ -d ./dist ]; then rm -r ./dist; fi && parcel build ./client/index.html --no-source-maps", 20 | "start": "ts-node --transpile-only --project ./node.tsconfig.json ./server/index.ts" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/Mysbeario/untitled-card-game.git" 25 | }, 26 | "keywords": [], 27 | "author": "", 28 | "license": "ISC", 29 | "bugs": { 30 | "url": "https://github.com/Mysbeario/untitled-card-game/issues" 31 | }, 32 | "homepage": "https://github.com/Mysbeario/untitled-card-game#readme", 33 | "dependencies": { 34 | "@babel/core": "^7.14.3", 35 | "@babel/preset-react": "^7.13.13", 36 | "@babel/preset-typescript": "^7.13.0", 37 | "@emotion/react": "^11.4.0", 38 | "@emotion/styled": "^11.1.5", 39 | "@parcel/transformer-image": "^2.0.0-nightly.2325", 40 | "babel-plugin-transform-inline-environment-variables": "^0.4.3", 41 | "dotenv": "^8.6.0", 42 | "express": "^4.17.1", 43 | "howler": "^2.2.1", 44 | "immer": "^9.0.1", 45 | "jotai": "^0.16.8", 46 | "normalize.css": "^8.0.1", 47 | "parcel": "^2.0.0-nightly.701", 48 | "react": "^17.0.1", 49 | "react-dom": "^17.0.1", 50 | "react-markdown": "^6.0.2", 51 | "react-spring": "^8.0.27", 52 | "socket.io": "^4.1.2", 53 | "socket.io-client": "^4.1.2", 54 | "ts-node": "^10.0.0", 55 | "typescript": "^4.3.2" 56 | }, 57 | "devDependencies": { 58 | "@types/express": "^4.17.12", 59 | "@types/howler": "^2.2.2", 60 | "@types/react": "^17.0.9", 61 | "@types/react-dom": "^17.0.6", 62 | "@typescript-eslint/eslint-plugin": "^4.26.0", 63 | "@typescript-eslint/parser": "^4.26.0", 64 | "eslint": "^7.27.0", 65 | "eslint-config-prettier": "^8.1.0", 66 | "eslint-plugin-prettier": "^3.3.1", 67 | "eslint-plugin-react": "^7.24.0", 68 | "prettier": "^2.3.0", 69 | "ts-node-dev": "^1.1.6" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /client/components/Card/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { CardInfo } from "../../../shared/@types"; 3 | import { SPELL_NAME } from "../../../shared/constants"; 4 | import { getNumberSign } from "../../../shared/utils"; 5 | import Icon from "../Icon"; 6 | import { centerizeStyle, disabledStyle } from "../../styles"; 7 | import { CardAction, cardChosenStyle, CardContent, CardPower, NormalCard, SmallCard } from "./styles"; 8 | import useMediaQuery from "../../hooks/useMediaQuery"; 9 | import { css } from "@emotion/react"; 10 | import { COLOR } from "../../constants"; 11 | import { shadeColor } from "../../utils"; 12 | 13 | type CardProps = { 14 | card: CardInfo; 15 | onClick?: (id: string) => void; 16 | chosen?: boolean; 17 | disabled?: boolean; 18 | className?: string; 19 | small?: boolean; 20 | }; 21 | 22 | const powerColor = (power: number) => shadeColor(power >= 0 ? COLOR.Info : COLOR.Danger, 30); 23 | 24 | const handleSpellName = (card: CardInfo) => 25 | card.spell !== SPELL_NAME.Void ? card.spell : card.power >= 0 ? "charge" : "consume"; 26 | 27 | const Card = ({ 28 | onClick, 29 | chosen = false, 30 | card, 31 | disabled = false, 32 | small = false, 33 | className, 34 | }: CardProps): JSX.Element => { 35 | const matchMedia = useMediaQuery("(max-width: 325px)"); 36 | 37 | const choose = (event: React.MouseEvent): void => { 38 | if (!disabled && onClick) { 39 | event.stopPropagation(); 40 | onClick(card.id); 41 | } 42 | }; 43 | 44 | return small || matchMedia ? ( 45 | 46 |
{getNumberSign(card.power) + Math.abs(card.power)}
47 | 48 |
49 | ) : ( 50 | 51 | 52 | 57 | {Math.abs(card.power)} 58 | 59 | 60 | 65 | {getNumberSign(card.power)} 66 | 67 | 68 | 69 | ); 70 | }; 71 | 72 | export default memo(Card); 73 | -------------------------------------------------------------------------------- /client/views/Hub/Header/Settings/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { useEffect } from "react"; 3 | import { audioSettingsAtom, chosenLanguageAtom, languageAtom, musicAtom, soundAtom } from "../../../../atoms"; 4 | import CenterizedGrid from "../../../../components/CenterizedGrid"; 5 | import Dialog from "../../../../components/Dialog"; 6 | import H1 from "../../../../components/H1"; 7 | import Icon from "../../../../components/Icon"; 8 | import useShowDialog from "../../../../hooks/useShowDialog"; 9 | import { clickableIconStyle } from "../../../../styles"; 10 | import { PartialSettings, toggleStyle } from "./styles"; 11 | 12 | const Settings = (): JSX.Element => { 13 | const [shouldDialogShow, dialogAction] = useShowDialog(); 14 | const [sound] = useAtom(soundAtom); 15 | const [music] = useAtom(musicAtom); 16 | const [chosenLanguage, chooseLanguage] = useAtom(chosenLanguageAtom); 17 | const [language] = useAtom(languageAtom); 18 | const [settings, setSettings] = useAtom(audioSettingsAtom); 19 | 20 | const toggleSound = () => setSettings({ ...settings, sound: !settings.sound }); 21 | const toggleMusic = () => setSettings({ ...settings, music: !settings.music }); 22 | 23 | useEffect(() => { 24 | sound?.play("Accept"); 25 | sound?.mute(!settings.sound); 26 | music?.mute(!settings.music); 27 | }, [settings]); 28 | 29 | return ( 30 | <> 31 | 32 | 33 | 34 | 35 |

{language.audio}

36 | 37 | 38 |
39 | 40 |

{language.language}

41 | chooseLanguage("vi")} 44 | css={[chosenLanguage !== "vi" && toggleStyle, clickableIconStyle]} 45 | /> 46 | chooseLanguage("en")} 49 | css={[chosenLanguage !== "en" && toggleStyle, clickableIconStyle]} 50 | /> 51 |
52 |
53 |
54 | 55 | ); 56 | }; 57 | 58 | export default Settings; 59 | -------------------------------------------------------------------------------- /client/views/Game/Status/Spells/index.tsx: -------------------------------------------------------------------------------- 1 | import produce from "immer"; 2 | import { memo, useState } from "react"; 3 | import { animated, useTransition } from "react-spring"; 4 | import { PassiveActionInfo, PurifyInfo, SpellInfo } from "../../../../../shared/@types"; 5 | import { SERVER_EVENT_NAME } from "../../../../../shared/constants"; 6 | import { useListenServerEvent } from "../../../../hooks"; 7 | import { fadeOut } from "../../../../styles"; 8 | import SpellIndicator from "./SpellIndicator"; 9 | import { StyledSpells } from "./styles"; 10 | 11 | type SpellsProps = { 12 | id: string; 13 | justifyContent?: "center" | "left"; 14 | }; 15 | 16 | const Spells = ({ id, justifyContent = "center" }: SpellsProps): JSX.Element => { 17 | const [spells, setSpells] = useState>({}); 18 | const transitions = useTransition(Object.values(spells), (s) => s.id, { 19 | from: { opacity: 0, maxWidth: "0px" }, 20 | enter: [{ maxWidth: "100px" }, { opacity: 1 }], 21 | leave: fadeOut, 22 | }); 23 | 24 | useListenServerEvent(SERVER_EVENT_NAME.Purify, (info: PurifyInfo) => { 25 | const { target, remove } = info; 26 | 27 | setSpells((list) => 28 | produce(list, (draft) => { 29 | if (target === id) remove.forEach((r) => delete draft[r]); 30 | }) 31 | ); 32 | }); 33 | 34 | useListenServerEvent(SERVER_EVENT_NAME.TakeSpell, (spell: SpellInfo) => { 35 | const { duration, target } = spell; 36 | 37 | setSpells((list) => 38 | produce(list, (draft) => { 39 | if (target === id && (duration > 0 || duration === -1 || draft[spell.id])) draft[spell.id] = spell; 40 | }) 41 | ); 42 | }); 43 | 44 | useListenServerEvent(SERVER_EVENT_NAME.ActivatePassive, (passive: PassiveActionInfo) => { 45 | const { target } = passive; 46 | setSpells((list) => 47 | produce(list, (draft) => { 48 | if (target === id && draft[passive.id]) draft[passive.id].strength = 0; 49 | }) 50 | ); 51 | }); 52 | 53 | useListenServerEvent(SERVER_EVENT_NAME.NewTurn, () => 54 | setSpells((list) => 55 | produce(list, (draft) => { 56 | for (const id in draft) if (draft[id].duration === 0 || draft[id].strength === 0) delete draft[id]; 57 | }) 58 | ) 59 | ); 60 | 61 | return ( 62 | 63 | {transitions.map(({ item, props, key }) => ( 64 | 65 | 66 | 67 | ))} 68 | 69 | ); 70 | }; 71 | 72 | export default memo(Spells); 73 | -------------------------------------------------------------------------------- /client/views/Hub/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { useEffect, useState } from "react"; 3 | import BoxOfCardsSprite from "url:../../assets/sprites/box_of_cards.png"; 4 | import IconSprites from "url:../../assets/sprites/icons.png"; 5 | import LoadingSpriteSheet from "url:../../assets/sprites/loading_animation.png"; 6 | import Logo from "url:../../assets/sprites/logo.png"; 7 | import { GameMatchingStatus } from "../../../shared/@types"; 8 | import { SERVER_EVENT_NAME } from "../../../shared/constants"; 9 | import { chosenLanguageAtom, routeAtom, soundAtom } from "../../atoms"; 10 | import CenterizedGrid from "../../components/CenterizedGrid"; 11 | import Page from "../../components/Page"; 12 | import Sprite from "../../components/Sprite"; 13 | import { ROUTE } from "../../constants"; 14 | import withSpriteLoading from "../../HOCs/withSpriteLoading"; 15 | import { useListenServerEvent } from "../../hooks"; 16 | import socket from "../../services/socket"; 17 | import FindingGame from "./FindingGame"; 18 | import GameConfirmDialog from "./GameConfirmDialog"; 19 | import Header from "./Header"; 20 | import Menu from "./Menu"; 21 | import PlayerNameInput from "./PlayerNameInput"; 22 | import Room from "./Room"; 23 | 24 | const Hub = (): JSX.Element => { 25 | const [sound] = useAtom(soundAtom); 26 | const [, changeRoute] = useAtom(routeAtom); 27 | const [isMatching, match] = useState(false); 28 | const [chosenLanguage] = useAtom(chosenLanguageAtom); 29 | 30 | useEffect(() => void socket.once(SERVER_EVENT_NAME.NewGame, () => changeRoute(ROUTE.InGame)), []); 31 | 32 | useListenServerEvent(SERVER_EVENT_NAME.UpdateGameMatchingStatus, (status: GameMatchingStatus) => 33 | match(status !== "Canceled") 34 | ); 35 | 36 | useListenServerEvent(SERVER_EVENT_NAME.FriendJoined, () => sound?.play("KnockDoor")); 37 | useListenServerEvent(SERVER_EVENT_NAME.FriendLeft, () => sound?.play("DoorClose")); 38 | useListenServerEvent(SERVER_EVENT_NAME.LeftRoom, () => sound?.play("DoorClose")); 39 | useListenServerEvent(SERVER_EVENT_NAME.JoinedRoom, () => sound?.play("KnockDoor")); 40 | 41 | return ( 42 | 43 | 44 |
45 | 52 | 53 | {isMatching ? ( 54 | 55 | ) : ( 56 | <> 57 | 58 | 59 | 60 | )} 61 | 62 | 63 | 64 | ); 65 | }; 66 | 67 | export default withSpriteLoading(Hub, [IconSprites, LoadingSpriteSheet, Logo, BoxOfCardsSprite]); 68 | -------------------------------------------------------------------------------- /server/entities/Player/index.ts: -------------------------------------------------------------------------------- 1 | import { SERVER_EVENT_NAME, SPELL_NAME } from "../../../shared/constants"; 2 | import { waitFor } from "../../utilities"; 3 | import Card from "../Card"; 4 | import Client from "../Client"; 5 | import ClientDerived from "../Client/ClientDerived"; 6 | import InGameState from "../Client/State/InGameState"; 7 | import InTurnState from "../Client/State/InTurnState"; 8 | import Game from "../Game"; 9 | import Spell from "../Spell"; 10 | import SpellManager from "./SpellManager"; 11 | 12 | class Player extends ClientDerived { 13 | public isEliminated = false; 14 | private cards: Card[] = []; 15 | private spellManager; 16 | private hitPoint: number; 17 | 18 | constructor(client: Client, private game: Game) { 19 | super(client); 20 | 21 | this.hitPoint = this.game.maxHP; 22 | this.spellManager = new SpellManager(this, this.game.broadcast.bind(this.game)); 23 | 24 | this.client.changeState(new InGameState(this.client, this, this.game)); 25 | } 26 | 27 | public getHitPoint(): number { 28 | return this.hitPoint; 29 | } 30 | 31 | public getClient(): Client { 32 | return this.client; 33 | } 34 | 35 | public startTurn(): void { 36 | this.client.changeState(new InTurnState(this.client, this, this.game)); 37 | } 38 | 39 | public async update(): Promise { 40 | await this.spellManager.triggerPendingDebuffs(); 41 | } 42 | 43 | public takeCards(...cards: Card[]): void { 44 | this.cards.push(...cards); 45 | this.client.getSocket().emit( 46 | SERVER_EVENT_NAME.GetCards, 47 | cards.map((c) => ({ id: c.id, power: c.getPower(), spell: c.getSpell() })) 48 | ); 49 | } 50 | 51 | public async playCard(id: string): Promise { 52 | const card = this.cards.find((c) => c.id === id); 53 | 54 | if (card) { 55 | this.game.endTurn(); 56 | this.client.changeState(new InGameState(this.client, this, this.game)); 57 | 58 | this.cards = this.cards.filter((c) => c.id !== id); 59 | this.game.discardCard(card); 60 | 61 | this.game.broadcast(SERVER_EVENT_NAME.CardPlayed, { 62 | id: card.id, 63 | power: card.getPower(), 64 | spell: [SPELL_NAME.Shield, SPELL_NAME.Mirror].includes(card.getSpell()) ? SPELL_NAME.Void : card.getSpell(), 65 | }); 66 | 67 | await waitFor(1000); 68 | await this.game.consumeCard(card); 69 | } else this.socket.emit(SERVER_EVENT_NAME.Notify, "errGeneric", "Danger"); 70 | } 71 | 72 | public changeHitPoint(difference: number): void { 73 | this.hitPoint += difference; 74 | 75 | if (this.hitPoint <= 0) this.hitPoint = 0; 76 | else if (this.hitPoint > this.game.maxHP) this.hitPoint = this.game.maxHP; 77 | 78 | this.game.broadcast(SERVER_EVENT_NAME.HitPointChanged, this.id, this.hitPoint); 79 | } 80 | 81 | public async takeSpell(spell: Spell): Promise { 82 | await this.spellManager.takeSpell(spell); 83 | } 84 | 85 | public purify(): void { 86 | this.spellManager.purify(); 87 | } 88 | } 89 | 90 | export default Player; 91 | -------------------------------------------------------------------------------- /client/views/Game/SpellAnimation.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { memo, useCallback, useRef, useState } from "react"; 3 | import SpellAnimationSprite from "url:../../assets/sprites/spell_animations.png"; 4 | import { PassiveActionInfo, SpellInfo } from "../../../shared/@types"; 5 | import { PASSIVE_ACTION, SERVER_EVENT_NAME, SPELL_NAME } from "../../../shared/constants"; 6 | import { soundAtom } from "../../atoms"; 7 | import Sprite from "../../components/Sprite"; 8 | import { useListenServerEvent } from "../../hooks"; 9 | import { centerizeStyle } from "../../styles"; 10 | 11 | type Effects = SPELL_NAME | PASSIVE_ACTION; 12 | 13 | const STEPS = 8; 14 | const WIDTH = 62; 15 | const HEIGHT = 46; 16 | 17 | const SPELL_ANIMATION_POS: Effects[] = [ 18 | SPELL_NAME.Heal, 19 | SPELL_NAME.Mirror, 20 | PASSIVE_ACTION.MirrorPierce, 21 | PASSIVE_ACTION.Reflect, 22 | SPELL_NAME.Poison, 23 | SPELL_NAME.Punch, 24 | SPELL_NAME.Shield, 25 | PASSIVE_ACTION.Block, 26 | PASSIVE_ACTION.ShieldPierce, 27 | ]; 28 | 29 | type SpellAnimationProps = { 30 | id: string; 31 | scale?: number; 32 | }; 33 | 34 | const SpellAnimation = ({ id, scale = 2 }: SpellAnimationProps): JSX.Element => { 35 | const [sound] = useAtom(soundAtom); 36 | const [spell, setSpell] = useState(SPELL_NAME.Void); 37 | const spellSounds = useRef void>>>({ 38 | [SPELL_NAME.Punch]: (frame) => { 39 | if (frame === 1) sound?.play("Swoosh"); 40 | if (frame === 3) sound?.play("Punch"); 41 | }, 42 | [SPELL_NAME.Poison]: (frame) => { 43 | if (frame === 1) sound?.play("Poison"); 44 | }, 45 | [SPELL_NAME.Heal]: (frame) => { 46 | if (frame === 1) sound?.play("Heal"); 47 | }, 48 | [SPELL_NAME.Shield]: (frame) => { 49 | if (frame === 1) sound?.play("Shield"); 50 | }, 51 | [SPELL_NAME.Mirror]: (frame) => { 52 | if (frame === 1) sound?.play("Mirror"); 53 | }, 54 | [PASSIVE_ACTION.Reflect]: (frame) => { 55 | if (frame === 3) sound?.play("MirrorReflect"); 56 | }, 57 | [PASSIVE_ACTION.Block]: (frame) => { 58 | if (frame === 1) sound?.play("ShieldBlock"); 59 | }, 60 | [PASSIVE_ACTION.ShieldPierce]: (frame) => { 61 | if (frame === 1) sound?.play("ShieldBreak"); 62 | }, 63 | [PASSIVE_ACTION.MirrorPierce]: (frame) => { 64 | if (frame === 1) sound?.play("MirrorBreak"); 65 | }, 66 | }); 67 | 68 | const onAnimationEnd = useCallback(() => setSpell(SPELL_NAME.Void), []); 69 | 70 | useListenServerEvent(SERVER_EVENT_NAME.TakeSpell, (spell: SpellInfo) => spell.target === id && setSpell(spell.name)); 71 | useListenServerEvent( 72 | SERVER_EVENT_NAME.ActivatePassive, 73 | (passive: PassiveActionInfo) => passive.target === id && setSpell(passive.action) 74 | ); 75 | 76 | return ( 77 | <> 78 | {spell !== SPELL_NAME.Void && ( 79 | 89 | )} 90 | 91 | ); 92 | }; 93 | 94 | export default memo(SpellAnimation); 95 | -------------------------------------------------------------------------------- /server/entities/Room.ts: -------------------------------------------------------------------------------- 1 | import { RoomInfo } from "../../shared/@types"; 2 | import { SERVER_EVENT_NAME } from "../../shared/constants"; 3 | import { MAX_PLAYERS_PER_GAME } from "../../shared/config"; 4 | import { generateUniqueId } from "../../shared/utils"; 5 | import Client from "./Client"; 6 | import IdleState from "./Client/State/IdleState"; 7 | import InRoomState from "./Client/State/InRoomState"; 8 | import RoomOwnerState from "./Client/State/RoomOwnerState"; 9 | import Server from "./Server"; 10 | 11 | class Room { 12 | public readonly id = generateUniqueId(); 13 | public isInGame = false; 14 | private members: Client[] = []; 15 | private blacklist: Client[] = []; 16 | private ownerId: string; 17 | 18 | constructor(owner: Client) { 19 | this.members.push(owner); 20 | this.ownerId = owner.getId(); 21 | owner.changeState(new RoomOwnerState(owner, this)); 22 | } 23 | 24 | public getMembers(): Client[] { 25 | return this.members; 26 | } 27 | 28 | public getInfo(): RoomInfo { 29 | return { 30 | id: this.id, 31 | owner: this.ownerId, 32 | members: this.members.map((g) => g.getInfo()), 33 | }; 34 | } 35 | 36 | public getSize(): number { 37 | return this.members.length; 38 | } 39 | 40 | public add(client: Client): void { 41 | if (this.isInGame) throw Error("errRoomInGame"); 42 | if (this.members.length >= MAX_PLAYERS_PER_GAME) throw Error("errRoomFull"); 43 | if (this.members.includes(client)) throw Error("errGeneric"); 44 | if (this.blacklist.includes(client)) throw Error("errNoPremission"); 45 | 46 | this.members.forEach((m) => m.getSocket().emit(SERVER_EVENT_NAME.FriendJoined, client.getInfo())); 47 | this.members.push(client); 48 | client.getSocket().emit(SERVER_EVENT_NAME.JoinedRoom); 49 | client.changeState(new InRoomState(client, this)); 50 | } 51 | 52 | public remove(client: Client): void { 53 | client.changeState(new IdleState(client)); 54 | 55 | if (client.getId() === this.ownerId) { 56 | const newOwner = this.members.find((m) => m.getId() !== this.ownerId); 57 | if (newOwner) { 58 | this.ownerId = newOwner.getId(); 59 | // We must not change the players state if they are in a game, because the players will lost their in game state 60 | if (newOwner.getState() instanceof InRoomState) newOwner.changeState(new RoomOwnerState(newOwner, this)); 61 | } 62 | } 63 | 64 | client.getSocket().emit(SERVER_EVENT_NAME.LeftRoom); 65 | this.members = this.members.filter((g) => g !== client); 66 | this.members.forEach((m) => m.getSocket().emit(SERVER_EVENT_NAME.FriendLeft, client.getId(), this.ownerId)); 67 | 68 | if (this.getSize() === 0) Server.getInstance().removeRoom(this); 69 | } 70 | 71 | public kick(id: string): void { 72 | if (id === this.ownerId) throw new Error("errGeneric"); 73 | 74 | const client = this.members.find((m) => m.getId() === id); 75 | 76 | if (client) { 77 | this.remove(client); 78 | this.blacklist.push(client); 79 | client.getSocket().emit(SERVER_EVENT_NAME.Notify, "notiKick", "Warning"); 80 | } else throw new Error("errGeneric"); 81 | } 82 | 83 | public back(client: Client): void { 84 | client.changeState( 85 | client.getId() === this.ownerId ? new RoomOwnerState(client, this) : new InRoomState(client, this) 86 | ); 87 | } 88 | } 89 | 90 | export default Room; 91 | -------------------------------------------------------------------------------- /shared/@types.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from "socket.io"; 2 | import { CLIENT_EVENT_NAME, EMOTION, PASSIVE_ACTION, SERVER_EVENT_NAME, SPELL_NAME } from "./constants"; 3 | 4 | export type GameMatchingStatus = "Finding" | "Found" | "Canceled"; 5 | export type StyleVariation = "Primary" | "Danger" | "Safe" | "Info" | "Warning" | "Normal"; 6 | 7 | export type PassiveActionInfo = { 8 | id: string; 9 | target: string; 10 | action: PASSIVE_ACTION; 11 | message: string; 12 | attacker: { 13 | name: string; 14 | spell: SPELL_NAME; 15 | strength: number; 16 | }; 17 | defender: { 18 | name: string; 19 | spell: SPELL_NAME; 20 | strength: number; 21 | }; 22 | }; 23 | 24 | export type PurifyInfo = { 25 | target: string; 26 | remove: string[]; 27 | }; 28 | 29 | export type ClientInfo = { 30 | id: string; 31 | name: string; 32 | }; 33 | 34 | export type PlayerInfo = ClientInfo & { 35 | isEliminated: boolean; 36 | }; 37 | 38 | export type CardInfo = { 39 | id: string; 40 | power: number; 41 | spell: SPELL_NAME; 42 | }; 43 | 44 | export type SpellInfo = { 45 | id: string; 46 | name: SPELL_NAME; 47 | strength: number; 48 | duration: number; 49 | target: string; 50 | }; 51 | 52 | export type PassiveAction = { 53 | id: string; 54 | action: PASSIVE_ACTION; 55 | target: string; 56 | }; 57 | 58 | export type RoomInfo = { 59 | id: string; 60 | owner: string; 61 | members: ClientInfo[]; 62 | }; 63 | 64 | export interface EventsFromServer { 65 | [SERVER_EVENT_NAME.Notify]: (msg: string, variant: StyleVariation) => void; 66 | [SERVER_EVENT_NAME.UpdateGameMatchingStatus]: (status: GameMatchingStatus) => void; 67 | [SERVER_EVENT_NAME.GetGameSettings]: (maxHP: number, timePerTurn: number) => void; 68 | [SERVER_EVENT_NAME.GetPlayerList]: (list: PlayerInfo[]) => void; 69 | [SERVER_EVENT_NAME.NewGame]: () => void; 70 | [SERVER_EVENT_NAME.GetCards]: (cards: CardInfo[]) => void; 71 | [SERVER_EVENT_NAME.ChargePointChanged]: (point: number) => void; 72 | [SERVER_EVENT_NAME.Overcharged]: () => void; 73 | [SERVER_EVENT_NAME.CardPlayed]: (card: CardInfo) => void; 74 | [SERVER_EVENT_NAME.HitPointChanged]: (target: string, hp: number) => void; 75 | [SERVER_EVENT_NAME.PlayerEliminated]: (id: string) => void; 76 | [SERVER_EVENT_NAME.GameOver]: (id: string) => void; 77 | [SERVER_EVENT_NAME.NewTurn]: (id: string, deck: number) => void; 78 | [SERVER_EVENT_NAME.TakeSpell]: (spell: SpellInfo) => void; 79 | [SERVER_EVENT_NAME.ActivatePassive]: (passive: PassiveActionInfo) => void; 80 | [SERVER_EVENT_NAME.FriendJoined]: (friend: ClientInfo) => void; 81 | [SERVER_EVENT_NAME.FriendLeft]: (id: string, owner: string) => void; 82 | [SERVER_EVENT_NAME.GetRoomInfo]: (info: RoomInfo) => void; 83 | [SERVER_EVENT_NAME.LeftRoom]: () => void; 84 | [SERVER_EVENT_NAME.JoinedRoom]: () => void; 85 | [SERVER_EVENT_NAME.Purify]: (info: PurifyInfo) => void; 86 | [SERVER_EVENT_NAME.EmotionExpressed]: (sender: string, emotion: EMOTION) => void; 87 | } 88 | 89 | export interface EventsFromClient { 90 | [CLIENT_EVENT_NAME.Rename]: (name: string) => void; 91 | [CLIENT_EVENT_NAME.FindGame]: () => void; 92 | [CLIENT_EVENT_NAME.ReadyConfirm]: (ready: boolean) => void; 93 | [CLIENT_EVENT_NAME.PlayCard]: (id: string) => void; 94 | [CLIENT_EVENT_NAME.LeaveGame]: () => void; 95 | [CLIENT_EVENT_NAME.CreateRoom]: () => void; 96 | [CLIENT_EVENT_NAME.JoinRoom]: (id: string) => void; 97 | [CLIENT_EVENT_NAME.LeaveRoom]: () => void; 98 | [CLIENT_EVENT_NAME.CancelFindGame]: () => void; 99 | [CLIENT_EVENT_NAME.Kick]: (id: string) => void; 100 | [CLIENT_EVENT_NAME.ExpressEmotion]: (emotion: EMOTION) => void; 101 | } 102 | 103 | export type ClientSocket = Socket; 104 | export type GameSocket = Server; 105 | -------------------------------------------------------------------------------- /client/languages/en.ts: -------------------------------------------------------------------------------- 1 | const en: Record = { 2 | loadSprite: "Loading sprites", 3 | loadSfx: "Loading sound effects", 4 | loadMusic: "Loading music", 5 | connectServer: "Connecting to game server", 6 | errConnectTitle: "Connection error", 7 | reconnect: "Reconnect", 8 | reconnectMessage: "Reconnecting", 9 | failConnect: "Failed to contact with server", 10 | noEffectDescription: "No effect.", 11 | punchDescription: "Instantly inflict damage to all opponents.", 12 | poisonDescription: `Make all opponents to suffer damage in 3 turns. Can be stacked.`, 13 | healDescription: "Instantly recover HP and remove all debuffs.", 14 | shieldDescription: 15 | "Block an incoming spell that has strength equal or less than this. When a spell pierces through a Shield spell, **that spell strength will be reduced by 1**, and the next Shield spell will be activated, if available. Can be stacked.", 16 | mirrorDescription: 17 | "Block and recast an incoming spell that has strength equal or less than this to the caster. When a spell pierces through a Mirror spell, the next Mirror spell will be activated, if available. Can be stacked.", 18 | spellDictionary: "Spell Dictionary", 19 | about: "About", 20 | aboutDescription: ` 21 | Code & Art: 22 | 23 | - Phan Dung Tri (Me) 24 | - Email: phandungtri99@gmail.com 25 | 26 | Music: 27 | 28 | - Harm-Jan "3xBlast" Wiechers 29 | 30 | Fonts: 31 | 32 | - Roberto Mocci 33 | - Peter Hull 34 | - Stefanie Koerner (pheist) 35 | 36 | Sound effects: 37 | 38 | - From Itch.io: 39 | - ObsydianX 40 | - edwardcufaude 41 | - kronbits 42 | - And many from freesound.org 43 | `, 44 | settingsTitle: "Settings", 45 | tutorialTitle: "Tutorial", 46 | language: "Language", 47 | audio: "Audio", 48 | confirmation: "Confirmation", 49 | kick: "Kick", 50 | yes: "Yes", 51 | no: "No", 52 | roomKickMessage: "This player can not join this room anymore!", 53 | roomCode: "Room code", 54 | roomCodePlaceholder: "Enter room code", 55 | join: "Join", 56 | leave: "Leave", 57 | leaveMessage: "Do you really want to leave?", 58 | findingOpponents: "Finding opponents", 59 | cancel: "Cancel", 60 | gameFound: "Game found", 61 | accept: "Accept", 62 | reject: "Reject", 63 | waitingPlayers: "Waiting for other players", 64 | gameFoundMessage: "A game is ready, join?", 65 | createRoom: "Create room", 66 | play: "Play", 67 | change: "Change", 68 | victory: "Victory", 69 | defeat: "Defeat", 70 | continue: "Continue", 71 | victoryMessage: "Congratulation! You've won!", 72 | defeatMessage: "Try better next time!", 73 | prepare: "Prepare", 74 | errGeneric: "Something wrong happened!", 75 | errNameLength: "Name length must be 2 - 24 characters!", 76 | errRoomInGame: "Game has started in this room!", 77 | errRoomFull: "Room is full!", 78 | errNoPermission: "You aren't permitted to join this room!", 79 | errRoomNotFound: "Room not found!", 80 | errNotEnoughPlayer: "Not enough players!", 81 | errNotReadyInRoom: "Someone isn't ready!", 82 | errNotInTurn: "Not your turn!", 83 | notiKick: "You got kicked!", 84 | notiNameChanged: "Name changed!", 85 | notiInTurn: "It's your turn!", 86 | notiFailMatch: "Someone hasn't accepted the game!", 87 | shieldBlockMessage: "is blocked by", 88 | mirrorReflectMessage: "is reflected by", 89 | shieldPiercedMessage: "is reduced 1 damage but can't be stop by", 90 | mirrorPiercedMessage: "can't be stop by", 91 | of: "of", 92 | bonk: "bonk!", 93 | nervous: "nervous", 94 | laugh: "laugh", 95 | angry: "angry", 96 | gameplayStarting: `Everyone in the game will start with **5 cards and 100 HP**. At the beginning of each turn, you will receive one more card.`, 97 | gameplayCard: `This is the **card**. Every turn, you **have to play one card** from your hand, or you will be eliminated when the timer runs out.\n 98 | When a card is played, a number on the top of that card, called **power**, will be charged to or consumed from the charger of the box, based on what symbol at the bottom that a card has, called **action**. **+** for charging and **-** for consuming.\n 99 | Example with the card above, it will add 3 points to the box.`, 100 | gameplayBox: `The box can hold **maximum 10 points**. If it's charged too many or consumed too many, it will be overcharged.\n 101 | When the box is overcharged, it loses all of its points and deal 10 damages to the player who caused this incident.`, 102 | gameplaySpell: `The icon in the middle of a card is its special ability, called **spell**. A spell is casted when the card is played and it will use current points of the box as its **strength**.\n 103 | Example the box has 5 points then when the above card is played, the Poison spell will have 5 strength and the box will charged to 8 points.\n 104 | Spell with 0 strength or spell of the overcharging card will have no effect.`, 105 | }; 106 | 107 | export default en; 108 | -------------------------------------------------------------------------------- /client/languages/vi.ts: -------------------------------------------------------------------------------- 1 | const vi: Record = { 2 | loadSprite: "Đang tải hình ảnh", 3 | loadSfx: "Đang tải hiệu ứng âm thanh", 4 | loadMusic: "Đang tải nhạc nền", 5 | connectServer: "Đang kết nối đến máy chủ trò chơi", 6 | errConnectTitle: "Lỗi kết nối", 7 | reconnect: "Kết nối lại", 8 | reconnectMessage: "Đang kết nối lại", 9 | failConnect: "Không thể kết nối đến máy chủ", 10 | noEffectDescription: "Không có hiệu ứng.", 11 | punchDescription: "Lập tức gây sát thương lên toàn bộ đối thủ.", 12 | poisonDescription: "Gây sát thương cho toàn bộ đối thủ trong 3 lượt. Có cộng dồn.", 13 | healDescription: "Hồi HP ngay lập tức và huỷ toàn bộ các hiệu ứng bất lợi.", 14 | shieldDescription: 15 | "Cản một phép từ đối thủ có sức mạnh bằng hoặc nhỏ hơn phép này. Nếu một phép vượt qua được phép Khiên, **phép đó sẽ bị giảm 1 sức mạnh** và phép Khiên tiếp theo sẽ được kích hoạt, nếu có. Có cộng dồn", 16 | mirrorDescription: 17 | "Cản và phản đòn phép từ đối thủ có sức mạnh bằng hoặc nhỏ hơn phép này. Nếu một phép vượt qua được phép Gương, thì phép Gương tiếp theo sẽ được kích hoạt, nếu có. Có cộng dồn", 18 | spellDictionary: "Từ điển phép", 19 | about: "Thông tin", 20 | aboutDescription: ` 21 | Lập trình & Hình ảnh: 22 | 23 | - Phan Dũng Trí 24 | - Email: phandungtri99@gmail.com 25 | 26 | Nhạc nền: 27 | 28 | - Harm-Jan "3xBlast" Wiechers 29 | 30 | Phông chữ: 31 | 32 | - Roberto Mocci 33 | - Peter Hull 34 | - Stefanie Koerner (pheist) 35 | 36 | Hiệu ứng âm thanh: 37 | 38 | - Từ Itch.io: 39 | - ObsydianX 40 | - edwardcufaude 41 | - kronbits 42 | - Và rất nhiều từ freesound.org 43 | `, 44 | settingsTitle: "Cài đặt", 45 | tutorialTitle: "Hướng dẫn", 46 | language: "Ngôn ngữ", 47 | audio: "Âm thanh", 48 | confirmation: "Xác nhận", 49 | kick: "Đuổi", 50 | yes: "Có", 51 | no: "Không", 52 | roomKickMessage: "Người chơi này sẽ không bào giờ tham gia phòng này được nữa!", 53 | roomCode: "Mã phòng", 54 | roomCodePlaceholder: "Nhập mã phòng", 55 | join: "Tham gia", 56 | leave: "Rời đi", 57 | leaveMessage: "Bạn thật sự muốn rời đi chứ?", 58 | findingOpponents: "Đang tìm đối thủ", 59 | cancel: "Huỷ", 60 | gameFound: "Đã tìm được trận", 61 | accept: "Chấp nhận", 62 | reject: "Từ chối", 63 | waitingPlayers: "Đang chờ người chơi khác", 64 | gameFoundMessage: "Một trận đã sẵn sàng, tham gia chứ?", 65 | createRoom: "Tạo phòng", 66 | play: "Chơi", 67 | change: "Thay đổi", 68 | victory: "Chiến thắng", 69 | defeat: "Thất bại", 70 | continue: "Tiếp tục", 71 | victoryMessage: "Chúc mừng! Bạn thắng rồi!", 72 | defeatMessage: "Lần sau cố gắng hơn nha!", 73 | prepare: "Chuẩn bị", 74 | errGeneric: "Điều gì đó không đúng vừa xảy ra!", 75 | errNameLength: "Độ dài tên phải từ 2 - 24 ký tự!", 76 | errRoomInGame: "Trận đấu đã bắt đầu trong phòng này!", 77 | errRoomFull: "Phòng đã đầy!", 78 | errNoPermission: "Bạn không được phép tham gia phòng này!", 79 | errRoomNotFound: "Không tìm thấy phòng!", 80 | errNotEnoughPlayer: "Không đủ người chơi!", 81 | errNotReadyInRoom: "Có ai đó chưa sẵn sàng!", 82 | errNotInTurn: "Chưa đến lượt của bạn!", 83 | notiKick: "Bạn vừa bị đuổi!", 84 | notiNameChanged: "Đã đổi tên!", 85 | notiInTurn: "Đến lượt bạn rồi!", 86 | notiFailMatch: "Ai đó đã không chấp nhận trận đấu!", 87 | shieldBlockMessage: "bị cản bởi", 88 | mirrorReflectMessage: "bị phản đòn bởi", 89 | shieldPiercedMessage: "bị giảm 1 sát thương nhưng không thể bị cản bởi", 90 | mirrorPiercedMessage: "không thể bị cản bởi", 91 | of: "của", 92 | bonk: "bonk!", 93 | nervous: "Lắng lo", 94 | laugh: "Cười", 95 | angry: "Tứk", 96 | gameplayStarting: `Mỗi người chơi trong trận đấu sẽ bắt đầu với **5 thẻ bài và 100 HP**. Ở đầu mỗi lượt chơi, bạn sẽ nhận thêm một thẻ nữa.`, 97 | gameplayCard: `Đây là **thẻ bài**. Mỗi lượt, bạn **bắt buộc phải đánh ra một thẻ** từ trên tay, hoặc bạn sẽ bị loại khi hết thời gian.\n 98 | Khi một thẻ bài được đánh ra, con số nằm ở phía trên cùng của thẻ bài, gọi là **năng lượng**, sẽ được sạc vào hoặc tiêu thụ từ bộ sạc của chiếc hộp, dựa vào ký hiệu ở phía dưới cùng của thẻ bài, gọi là **hành động**. **+** là sạc và **-** là tiêu thụ.\n 99 | Ví dụ với thẻ bài bên trên, nó sẽ sạc 3 điểm vào chiếc hộp.`, 100 | gameplayBox: `Chiếc hộp tích được **tối đa 10 điểm**. Nếu nó bị sạc quá nhiều hay tiêu thụ quá nhiều, nó sẽ bị quá tải.\n 101 | Khi chiếc hộp bị quá tải, nó mất toàn bộ số điểm và gây 10 sát thương lên người chơi gây ra sự cố này.`, 102 | gameplaySpell: `Biểu tượng ở giữa thẻ bài là khả năng đặc biệt của nó, gọi là **phép**. Một phép được thi triển khi thẻ bài được đánh ra và nó sẽ sử dụng số điểm hiện tại của chiếc hộp làm **sức mạnh**.\n 103 | Ví dụ chiếc hộp có 5 điểm thì khi thẻ bài bên trên được đánh ra, phép Độc sẽ có sức mạnh là 5 và chiếc hộp được sạc lên 8 điểm.\n 104 | Phép với sức mạnh bằng 0 hay phép của thẻ bài gây ra quá tải sẽ không có hiệu ứng gì hết.`, 105 | }; 106 | 107 | export default vi; 108 | -------------------------------------------------------------------------------- /client/views/Initiator/index.tsx: -------------------------------------------------------------------------------- 1 | import { Howl } from "howler"; 2 | import { useAtom } from "jotai"; 3 | import { useEffect, useRef, useState } from "react"; 4 | import SoundEffects from "url:../../assets/sounds/effects.mp3"; 5 | import Music from "url:../../assets/sounds/music.mp3"; 6 | import BoxOfCardSprites from "url:../../assets/sprites/box_of_cards.png"; 7 | import ContentFrameSprite from "url:../../assets/sprites/card_content_frame.png"; 8 | import IconSprites from "url:../../assets/sprites/icons.png"; 9 | import LoadingSpriteSheet from "url:../../assets/sprites/loading_animation.png"; 10 | import SpellAnimations from "url:../../assets/sprites/spell_animations.png"; 11 | import Logo from "url:../../assets/sprites/logo.png"; 12 | import { chosenLanguageAtom, languageAtom, musicAtom, routeAtom, soundAtom, versionAtom } from "../../atoms"; 13 | import Loading from "../../components/Loading"; 14 | import Page from "../../components/Page"; 15 | import ProgressBar from "../../components/ProgressBar"; 16 | import { ROUTE } from "../../constants"; 17 | import socket from "../../services/socket"; 18 | import { centerizeStyle } from "../../styles"; 19 | import { LoadingProgress } from "./styles"; 20 | import WhatsNew from "./WhatsNew"; 21 | import { useLocalStorage } from "../../hooks"; 22 | 23 | const Initiator = (): JSX.Element => { 24 | const [clientVersion] = useLocalStorage("version", ""); 25 | const [version] = useAtom(versionAtom); 26 | const [, setRoute] = useAtom(routeAtom); 27 | const [, setSound] = useAtom(soundAtom); 28 | const [, setMusic] = useAtom(musicAtom); 29 | const [language] = useAtom(languageAtom); 30 | const [, setChosenLanguage] = useAtom(chosenLanguageAtom); 31 | const [isFirstLoading, firstLoad] = useState(true); 32 | const [shouldConnect, connect] = useState(false); 33 | const [message, setMessage] = useState(language.loadSprite); 34 | const [loadedAssetCounter, setLoadedAssetCounter] = useState(0); 35 | const spriteSources = useRef([ 36 | IconSprites, 37 | SpellAnimations, 38 | ContentFrameSprite, 39 | LoadingSpriteSheet, 40 | BoxOfCardSprites, 41 | Logo, 42 | ]); 43 | const total = useRef(spriteSources.current.length + 2); 44 | 45 | useEffect(() => { 46 | if (shouldConnect && clientVersion === version) setTimeout(() => setRoute(ROUTE.Hub), 1000); 47 | }, [shouldConnect]); 48 | 49 | useEffect(() => { 50 | fetch("https://extreme-ip-lookup.com/json/") 51 | .then((res) => res.json()) 52 | .then((response) => { 53 | if (response.countryCode === "VN") setChosenLanguage("vi"); 54 | else setChosenLanguage("en"); 55 | }) 56 | .catch(() => setChosenLanguage("en")) 57 | .finally(() => firstLoad(false)); 58 | }, []); 59 | 60 | useEffect(() => { 61 | if (isFirstLoading) return; 62 | if (loadedAssetCounter < spriteSources.current.length) { 63 | const img = new Image(); 64 | img.src = spriteSources.current[loadedAssetCounter]; 65 | img.onload = () => setLoadedAssetCounter(loadedAssetCounter + 1); 66 | } else if (loadedAssetCounter === spriteSources.current.length) { 67 | setMessage(language.loadSfx); 68 | setSound( 69 | new Howl({ 70 | src: [SoundEffects], 71 | sprite: { 72 | Accept: [0, 6000], 73 | Cancel: [7000, 6000], 74 | Defeat: [14000, 1500], 75 | DoorClose: [17000, 762.1541950113375], 76 | Eliminated: [19000, 835.9183673469381], 77 | Danger: [21000, 360.0226757369604], 78 | GameFound: [23000, 1058.8435374149653], 79 | Heal: [26000, 1450.657596371883], 80 | Info: [29000, 599.7278911564621], 81 | KnockDoor: [31000, 498.730158730158], 82 | Mirror: [33000, 1054.036281179137], 83 | MirrorBreak: [36000, 500], 84 | MirrorReflect: [38000, 835.1020408163237], 85 | Overcharged: [40000, 750], 86 | PlayCard: [42000, 501.0430839002282], 87 | Poison: [44000, 827.7324263038537], 88 | Pop: [46000, 417.9591836734673], 89 | Punch: [48000, 286.96145124716566], 90 | Safe: [50000, 1109.3424036281192], 91 | Shield: [53000, 1117.052154195008], 92 | ShieldBlock: [56000, 900.0907029478426], 93 | ShieldBreak: [58000, 603.5600907029491], 94 | Swoosh: [60000, 1109.3424036281192], 95 | TakeCard: [63000, 504.0362811791397], 96 | Victory: [65000, 2275.510204081627], 97 | Warning: [69000, 366.0997732426239], 98 | }, 99 | volume: 0.75, 100 | onload: () => setLoadedAssetCounter(loadedAssetCounter + 1), 101 | }) 102 | ); 103 | } else if (loadedAssetCounter === total.current - 1) { 104 | setMessage(language.loadMusic); 105 | setMusic( 106 | new Howl({ 107 | src: Music, 108 | autoplay: true, 109 | loop: true, 110 | volume: 0.1, 111 | onload: () => setLoadedAssetCounter(loadedAssetCounter + 1), 112 | }) 113 | ); 114 | } else if (loadedAssetCounter === total.current) { 115 | setMessage(language.connectServer); 116 | socket.connect(); 117 | socket.on("connect", () => connect(true)); 118 | } 119 | }, [loadedAssetCounter, isFirstLoading]); 120 | 121 | return ( 122 | 123 | 124 | 125 |

{message}

126 | 131 |
132 | {shouldConnect && clientVersion !== version && } 133 |
134 | ); 135 | }; 136 | 137 | export default Initiator; 138 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "ES2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "moduleResolution": "Node", 10 | "lib": [ 11 | "ES2016", 12 | "DOM", 13 | "ES2017.Object", 14 | "DOM.Iterable" 15 | ] /* Specify library files to be included in the compilation. */, 16 | // "allowJs": true, /* Allow javascript files to be compiled. */ 17 | // "checkJs": true, /* Report errors in .js files. */ 18 | "jsx": "preserve" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 19 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 20 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 21 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 22 | // "outFile": "./", /* Concatenate and emit output to single file. */ 23 | // "outDir": "./", /* Redirect output structure to the directory. */ 24 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 25 | // "composite": true, /* Enable project compilation */ 26 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 27 | "removeComments": true /* Do not emit comments to output. */, 28 | // "noEmit": true, /* Do not emit outputs. */ 29 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 30 | "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, 31 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 32 | 33 | /* Strict Type-Checking Options */ 34 | "strict": true /* Enable all strict type-checking options. */, 35 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 36 | // "strictNullChecks": true, /* Enable strict null checks. */ 37 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 38 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 39 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 40 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 41 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 42 | 43 | /* Additional Checks */ 44 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 45 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 46 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 47 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 48 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 49 | 50 | /* Module Resolution Options */ 51 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 52 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 53 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 54 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 55 | // "typeRoots": [], /* List of folders to include type definitions from. */ 56 | // "types": [], /* Type declaration files to be included in compilation. */ 57 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, 58 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 59 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 60 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 61 | 62 | /* Source Map Options */ 63 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 64 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 65 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 66 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 67 | 68 | /* Experimental Options */ 69 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 70 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 71 | 72 | /* Advanced Options */ 73 | "skipLibCheck": true /* Skip type checking of declaration files. */, 74 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 75 | "jsxImportSource": "@emotion/react" 76 | }, 77 | "include": ["client/**/*", "server/**/*", "shared/**/*"], 78 | "exclude": ["node_modules", "dist"] 79 | } 80 | -------------------------------------------------------------------------------- /server/entities/Game/index.ts: -------------------------------------------------------------------------------- 1 | import { EventsFromServer } from "../../../shared/@types"; 2 | import { EMOTION, SERVER_EVENT_NAME, SPELL_NAME } from "../../../shared/constants"; 3 | import { DEFAULT_MAX_HP, DEFAULT_TIME_PER_TURN } from "../../../shared/config"; 4 | import { generateUniqueId } from "../../../shared/utils"; 5 | import { waitFor } from "../../utilities"; 6 | import Card from "../Card"; 7 | import Client from "../Client"; 8 | import GameLoadingChecker from "./GameLoadingChecker"; 9 | import Player from "../Player"; 10 | import Room from "../Room"; 11 | import SpellFactory from "../Spell/SpellFactory"; 12 | import Deck from "./Deck"; 13 | import IdleState from "../Client/State/IdleState"; 14 | 15 | const NUM_OF_STARTING_CARDS = 5; 16 | 17 | class Game { 18 | public readonly id = generateUniqueId(); 19 | private players: Player[] = []; 20 | private currentPlayerIndex = 0; 21 | private chargePoint = 0; 22 | private drawDeck = new Deck(); 23 | private discardDeck: Deck = new Deck(true); 24 | private timeout!: NodeJS.Timeout; 25 | 26 | constructor( 27 | clients: Client[], 28 | private room?: Room, 29 | public readonly maxHP = DEFAULT_MAX_HP, 30 | public readonly timePerTurn = DEFAULT_TIME_PER_TURN 31 | ) { 32 | new GameLoadingChecker(this, clients, this.room); 33 | clients.forEach((c) => c.getSocket().emit(SERVER_EVENT_NAME.NewGame)); 34 | } 35 | 36 | public getCurrentPlayer(): Player { 37 | return this.players[this.currentPlayerIndex]; 38 | } 39 | 40 | private shouldEnd(): boolean { 41 | // The game ends when there is only 1 player that hasn't been eliminated yet 42 | return this.players.reduce((count, p) => (p.isEliminated ? count : count + 1), 0) === 1; 43 | } 44 | 45 | private dealCards() { 46 | const startingHands: Card[][] = []; 47 | 48 | for (let i = 0; i < NUM_OF_STARTING_CARDS; i++) { 49 | for (let j = 0; j < this.players.length; j++) { 50 | if (!startingHands[j]) startingHands[j] = []; 51 | startingHands[j].push(this.drawCard()); 52 | } 53 | } 54 | 55 | return startingHands; 56 | } 57 | 58 | private async changeChargePoint(point: number): Promise { 59 | this.chargePoint = point; 60 | this.broadcast(SERVER_EVENT_NAME.ChargePointChanged, this.chargePoint); 61 | await waitFor(1000); 62 | } 63 | 64 | private async onOvercharged() { 65 | this.broadcast(SERVER_EVENT_NAME.Overcharged); 66 | this.getCurrentPlayer().changeHitPoint(-10); 67 | await this.changeChargePoint(0); 68 | } 69 | 70 | private async distributeSpell(spell: SPELL_NAME) { 71 | await SpellFactory.create( 72 | spell, 73 | this.chargePoint, 74 | this.players.filter((p) => !p.isEliminated), 75 | this.getCurrentPlayer() 76 | ); 77 | } 78 | 79 | private async newTurn() { 80 | clearTimeout(this.timeout); 81 | this.nextPlayer(); 82 | await this.updatePlayers(); 83 | 84 | if (this.shouldEnd()) this.end(); 85 | else { 86 | const currentPlayer = this.getCurrentPlayer(); 87 | 88 | currentPlayer.takeCards(this.drawCard()); 89 | currentPlayer.startTurn(); 90 | this.broadcast(SERVER_EVENT_NAME.NewTurn, currentPlayer.id, this.drawDeck.getSize()); 91 | 92 | this.timeout = setTimeout(() => this.eliminatePlayer(currentPlayer), this.timePerTurn); 93 | } 94 | } 95 | 96 | private async updatePlayers() { 97 | for (const p of this.players) { 98 | if (!p.isEliminated) { 99 | await p.update(); 100 | if (p.getHitPoint() <= 0) this.eliminatePlayer(p); 101 | } 102 | } 103 | } 104 | 105 | private nextPlayer() { 106 | do { 107 | this.currentPlayerIndex = (this.currentPlayerIndex + 1) % this.players.length; 108 | } while (this.getCurrentPlayer().isEliminated); 109 | } 110 | 111 | private end() { 112 | clearTimeout(this.timeout); 113 | if (this.room) this.room.isInGame = false; 114 | this.broadcast(SERVER_EVENT_NAME.GameOver, this.players.find((p) => !p.isEliminated)?.id || ""); 115 | } 116 | 117 | public drawCard(): Card { 118 | if (this.drawDeck.getSize() === 0) { 119 | this.drawDeck.copy(this.discardDeck); 120 | this.discardDeck.clear(); 121 | this.drawDeck.shuffle(); 122 | } 123 | 124 | return this.drawDeck.pop() as Card; 125 | } 126 | 127 | public discardCard(card: Card): void { 128 | this.discardDeck.push(card); 129 | } 130 | 131 | public endTurn(): void { 132 | clearTimeout(this.timeout); 133 | } 134 | 135 | public async consumeCard(card: Card): Promise { 136 | const newChargePoint = this.chargePoint + card.getPower(); 137 | 138 | if (newChargePoint < 0 || newChargePoint > 10) await this.onOvercharged(); 139 | else { 140 | await this.distributeSpell(card.getSpell()); 141 | await this.changeChargePoint(newChargePoint); 142 | } 143 | 144 | this.newTurn(); 145 | } 146 | 147 | public start(clients: Client[]): void { 148 | this.players = clients.map((cl) => new Player(cl, this)); 149 | this.currentPlayerIndex = this.players.length - 1; 150 | const startingHands = this.dealCards(); 151 | const playerList = this.players.map((p) => ({ 152 | ...p.getClient().getInfo(), 153 | isEliminated: p.isEliminated, 154 | })); 155 | 156 | this.players.forEach((p, i) => { 157 | p.socket.emit(SERVER_EVENT_NAME.GetGameSettings, this.maxHP, this.timePerTurn); 158 | p.socket.emit(SERVER_EVENT_NAME.GetPlayerList, playerList); 159 | p.takeCards(...startingHands[i]); 160 | }); 161 | 162 | this.newTurn(); 163 | } 164 | 165 | public eliminatePlayer(player: Player): void { 166 | if (!this.shouldEnd()) { 167 | player.isEliminated = true; 168 | this.broadcast(SERVER_EVENT_NAME.PlayerEliminated, player.id); 169 | 170 | if (this.getCurrentPlayer() === player) this.newTurn(); 171 | else if (this.shouldEnd()) this.end(); 172 | } 173 | } 174 | 175 | public removePlayer(player: Player): void { 176 | const client = player.getClient(); 177 | 178 | if (this.room) 179 | if (this.shouldEnd()) this.room.back(client); 180 | else this.room.remove(client); 181 | else client.changeState(new IdleState(client)); 182 | } 183 | 184 | public expressEmotion(player: Player, emotion: EMOTION): void { 185 | this.broadcast(SERVER_EVENT_NAME.EmotionExpressed, player.id, emotion); 186 | } 187 | 188 | public broadcast(event: SERVER_EVENT_NAME, ...data: Parameters): void { 189 | this.players.forEach((p) => p.socket.emit(event, ...data)); 190 | } 191 | } 192 | 193 | export default Game; 194 | --------------------------------------------------------------------------------