diff --git a/index.html b/index.html index df7ee75..cba7360 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + diff --git a/package-lock.json b/package-lock.json index 4651f1b..8117fcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-react": "^7.33.2", "firebase": "^10.1.0", - "prettier": "^3.0.3", + "prettier": "^3.3.3", "sass": "^1.77.1", "typescript": "^5.4.5", "vite": "^5.2.11", @@ -6730,10 +6730,11 @@ } }, "node_modules/prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, diff --git a/package.json b/package.json index ef6bea9..ff453c4 100644 --- a/package.json +++ b/package.json @@ -41,22 +41,22 @@ "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^13.5.0", "@types/jest": "^29.5.11", + "@types/lodash.debounce": "^4.0.7", "@types/node": "^20.8.9", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.7", "@types/three": "^0.164.0", - "@types/lodash.debounce": "^4.0.7", "@typescript-eslint/eslint-plugin": "^6.2.0", "@typescript-eslint/parser": "^6.8.0", "@vitejs/plugin-react": "^4.2.1", "classnames": "^2.3.2", "eslint": "^8.45.0", - "eslint-plugin-react": "^7.33.2", "eslint-config-prettier": "^8.8.0", "eslint-plugin-import": "^2.28.1", "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", "firebase": "^10.1.0", - "prettier": "^3.0.3", + "prettier": "^3.3.3", "sass": "^1.77.1", "typescript": "^5.4.5", "vite": "^5.2.11", diff --git a/src/App.tsx b/src/App.tsx index f661532..1e082f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { useStore } from './store/store'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { DirectionalLight, Vector3 } from 'three'; import './App.scss'; +import { TilePropertiesMenu } from './components/TilePropertiesMenu/TilePropertiesMenu'; const App = () => { const sun = useRef(null!); @@ -51,6 +52,7 @@ const App = () => { + ); }; diff --git a/src/components/BuildMenu/BuildMenu.scss b/src/components/BuildMenu/BuildMenu.scss index d3c3ea6..be99ea4 100644 --- a/src/components/BuildMenu/BuildMenu.scss +++ b/src/components/BuildMenu/BuildMenu.scss @@ -1,25 +1,84 @@ $build-menu-height: 64px; +@keyframes fadeIn { + from { + opacity: 0; + filter: blur(4px); + } + to { + opacity: 1; + filter: blur(0); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + filter: blur(0); + } + to { + opacity: 0; + filter: blur(4px); + } +} + +.fade-in { + animation: fadeIn 0.2s ; + will-change: opacity, filter; +} + +.fade-out { + animation: fadeOut 0.2s ; + will-change: opacity, filter; +} + .build-menu { - position: absolute; - bottom: 0; - bottom: env(safe-area-inset-bottom); - width: 100%; - background-color: #21494db3; - color: white; - height: 0; - transition: transform 0.2s cubic-bezier(0.59, 0.33, 0.16, 1.29); + position: fixed; + left: 50cqi; + bottom: calc(env(safe-area-inset-bottom) + 8%); + border-radius: 20px; + height: fit-content; + width: fit-content; + + background-color: rgba(33, 73, 77, 0.702); + @media (prefers-color-scheme: light) { + background-color: #d3c57a66; + } + @media (prefers-color-scheme: dark) { + background-color: rgba(33, 73, 77, 0.702); + } + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + color: #e6e6e6; display: flex; flex-direction: row; justify-content: center; - padding: 8px; - gap: 32px; - box-sizing: border-box; + align-items: baseline; + padding: 10px 48px; + gap: 38px; + box-sizing: content-box; backdrop-filter: saturate(180%) blur(30px); - transform: translateY($build-menu-height); + -webkit-backdrop-filter: saturate(180%) blur(30px); + + animation: fadeOut 0.4s forwards ease-out; + transform: translateX(50%); &--visible { - transform: translateY(0); + opacity: 1; + animation: fadeIn 0.3s forwards ease-in; + + transform: translateX(-50%) translateY(0); height: $build-menu-height; } + + &--hidden { + opacity: 0; + height: $build-menu-height; + } + + @media (orientation: landscape) { + max-height: 30%; + left: 20cqi; + top: 36cqi; + } } diff --git a/src/components/BuildMenu/BuildMenu.tsx b/src/components/BuildMenu/BuildMenu.tsx index 290246c..824af0c 100644 --- a/src/components/BuildMenu/BuildMenu.tsx +++ b/src/components/BuildMenu/BuildMenu.tsx @@ -50,9 +50,8 @@ export const BuildMenu = () => { const hasEnoughRessources = (building: BuildingType) => { const recipe = getCostsOfBuilding(building); let hasEnough = true; - Object.entries(recipe).forEach((cost) => { - if (ressources[cost[0] as Ressources] < recipe[cost[0] as Ressources]!) hasEnough = false; - }); + Object.entries(recipe).forEach(([key, value]) => { + if (ressources[key as Ressources] < value.amount) hasEnough = false; }); return hasEnough; }; @@ -60,7 +59,7 @@ export const BuildMenu = () => {
0), })} > {selected && existingBuilding === undefined ? ( diff --git a/src/components/BuildMenu/BuildMenuEntry/BuildMenuEntry.scss b/src/components/BuildMenu/BuildMenuEntry/BuildMenuEntry.scss index 3dad625..a0119ab 100644 --- a/src/components/BuildMenu/BuildMenuEntry/BuildMenuEntry.scss +++ b/src/components/BuildMenu/BuildMenuEntry/BuildMenuEntry.scss @@ -1,42 +1,40 @@ -.building-menu__entry { - all: unset; - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; - font-size: 12px; - height: 100%; - width: fit-content; - cursor: pointer; - user-select: none; - - &:hover .building-menu__entry-icon { - background: rgba(255, 255, 255, 0.6); +@keyframes fadeIn { + from { + opacity: 0; + filter: blur(4px); + } + to { + opacity: 1; + filter: blur(0); } } -.building-menu__entry-icon { - height: 32px; - width: 32px; - font-size: 20px; - line-height: 30px; - margin: 0; - border: 1px solid rgb(47, 192, 57); - color: rgb(47, 192, 57); - border-radius: 100%; - text-align: center; - transition: background 0.2s ease-out; +@keyframes fadeOut { + from { + opacity: 1; + filter: blur(0); + } + to { + opacity: 0; + filter: blur(4px); + } } -.building-menu__entry[disabled] { - filter: grayscale(1); - color: rgb(133, 129, 129); + +@keyframes subtleRing { + 0%, + 100% { + font-size: scale(1); + } + 50% { + transform: scale(1.05); + } } .building-menu__modal { position: fixed; - top: 50%; - left: 50%; + top: 45%; + left: 50cqi; transform: translate(-50%, -50%); display: flex; flex-direction: column; @@ -44,45 +42,93 @@ padding: 32px 48px; background: rgba(211, 197, 122, 0.4); backdrop-filter: saturate(180%) blur(30px); + -webkit-backdrop-filter: saturate(180%) blur(30px); border-radius: 8px; - width: 196px; - min-height: 196px; - + width: 60%; + z-index: 1000; + background-color: rgba(33, 73, 77, 0.702); + @media (prefers-color-scheme: light) { + background-color: #d3c57a66; + } + @media (prefers-color-scheme: dark) { + background-color: rgba(20, 44, 46, 0.802); + } + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.6); + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + opacity: 1; display: flex; flex-direction: column; gap: 16px; - color: white; + color: #d5d5d5; + + animation: fadeIn 0.18s forwards ease-in-out; + + p { + font-size: 0.9em; + animation: fadeIn 0.4s forwards ease-in-out; + overflow-y: auto; + } h2 { margin: 0; + animation: fadeIn 0.4s forwards ease-in-out; figure { display: inline-block; margin: 0 16px 0 0; - font-size: 1.2em; + font-size: 2em; } } + h3 { + animation: fadeIn 0.4s forwards ease-in-out; + } + ul { all: unset; list-style: none; + animation: fadeIn 0.4s forwards ease-in-out; li { width: 100%; display: flex; flex-direction: row; - justify-content: space-between; + justify-content: flex-start; } } + .building-menu__modal-accept-delete { + all: unset; + background: rgba(185, 12, 12, 0.439); + padding: 8px; + color: blueviolet; + border-radius: 50%; + text-align: center; + font-size: 20px; + height: 48px; + width: 48px; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2); + align-self: center; + animation: + fadeIn 0.4s forwards ease-in-out; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.67, 0.38, 0.09, 2.1); + margin-top: 8px; + } + .building-menu__modal-accept { all: unset; - background: rgba(211, 197, 122, 0.4); + background: rgba(12, 185, 61, 0.645); padding: 8px; - border-radius: 4px; + border-radius: 50%; text-align: center; - font-size: 0.8em; - font-weight: bold; + font-size: 20px; + height: 48px; + width: 48px; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2); + align-self: center; + animation: fadeIn 0.4s forwards ease-in-out; + cursor: pointer; transition: all 0.2s cubic-bezier(0.67, 0.38, 0.09, 2.1); margin-top: 8px; @@ -93,14 +139,25 @@ } } + .building-menu__modal-accept[disabled] { + background: rgba(178, 192, 182, 0.645); + cursor: not-allowed; + transition: none; + + &:hover { + transform: none; + box-shadow: none; + } + } + .building-menu__modal-close { all: unset; position: absolute; - top: 8px; - right: 8px; + top: 10px; + right: 16px; padding: 4px; cursor: pointer; - font-size: 0.8em; + font-size: 1.2em; font-weight: bold; transition: all 0.2s cubic-bezier(0.67, 0.38, 0.09, 2.1); @@ -108,8 +165,100 @@ transform: scale(1.05); } } + + @media (orientation: landscape) { + h3 { + font-size: 1em; + } + h2 { + font-size: 1.2em; + } + p { + font-size: 0.9em; + overflow-y: auto; + height: 30px; + } + width: fit-content; + top: 55%; + gap: 0px; + max-width: 42%; + max-height: 65%; + + ul li { + width: 20%; + } + ul { + display: flex; + flex-direction: column; + align-items: left; + font-size: 0.8em; + } + padding: 26px 32px; + + .building-menu__modal-accept { + font-size: 0.9em; + padding: 6px; + width: 35%; + position: relative; + top: 5cqb; + left: 35%; + height: 50px; + border-radius: 6px; + background: rgba(16, 162, 58, 0.645); + } + + .building-menu__modal-close { + all: unset; + position: absolute; + top: 12px; + right: 8px; + font-size: 15px; + padding: 2px; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.67, 0.38, 0.09, 2.1); + + &:hover { + transform: scale(1.05); + } + } + } } -h3 { - margin: 0; +.building-menu__entry { + all: unset; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + font-size: 14px; + height: 100%; + width: auto; + cursor: pointer; + user-select: none; + + .building-menu__entry-icon { + height: 42px; + width: 42px; + font-size: 32px; + line-height: 38px; + margin: 0; + border: 1px solid rgb(47, 192, 57); + color: rgb(47, 192, 57); + border-radius: 100%; + text-align: center; + background-color: rgb(47, 192, 57); + } + + &:hover .building-menu__entry-icon { + background: rgba(255, 255, 255, 0.6); + } + + &:disabled { + filter: grayscale(1); + color: rgba(255, 243, 243, 0.654); + box-shadow: 0 0px 0px rgba(0, 0, 0, 0.6); + text-shadow: 0px 0px 0px rgba(0, 0, 0, 0.5); + animation: subtleRing infinite; + } } diff --git a/src/components/BuildMenu/BuildMenuEntry/BuildMenuEntry.tsx b/src/components/BuildMenu/BuildMenuEntry/BuildMenuEntry.tsx index 4e49f50..1c23947 100644 --- a/src/components/BuildMenu/BuildMenuEntry/BuildMenuEntry.tsx +++ b/src/components/BuildMenu/BuildMenuEntry/BuildMenuEntry.tsx @@ -23,7 +23,6 @@ const BuildingMenuEntry = ({ hasEnoughRessources, }: BuildMenuEntryProps) => { const [modalOpen, setModalOpen] = useState(false); - const modalRef = useClickOutside(() => setModalOpen(false)); const buildingToRemove = buildingTypeToRemove !== undefined ? getBuildingByType(buildingTypeToRemove) : undefined; @@ -48,23 +47,25 @@ const BuildingMenuEntry = ({

{building.description}

Costs to build:

    - {Object.entries(building.costs).map((cost) => ( -
  • - {cost[0]}: {cost[1] * -1} + {Object.entries(building.costs).map(([key, value]) => ( +
  • + {value.emoji}{' '} + {value.amount * -1}
  • ))}
, document.body, @@ -74,28 +75,26 @@ const BuildingMenuEntry = ({ buildingToRemove && ( <> {modalOpen && createPortal(
-

remove {buildingToRemove.name}?

+

Delete {buildingToRemove.name}?

- Are you sure you want to remove this {buildingToRemove.name}? You will be refunded {SELLFACTOR * 100}% - of the building costs. + Do you wish to dismantle the {buildingToRemove.name}? You will reclaim {SELLFACTOR * 100}% of its + resources.

+ >💲
, document.body, diff --git a/src/components/Foilage/foilage/TreeBirch.tsx b/src/components/Foilage/foilage/TreeBirch.tsx index 9e61fb8..3df62af 100644 --- a/src/components/Foilage/foilage/TreeBirch.tsx +++ b/src/components/Foilage/foilage/TreeBirch.tsx @@ -6,8 +6,8 @@ source: https://sketchfab.com/3d-models/birch-tree-voxel-459f53f6981a4482834f770 title: Birch Tree Voxel */ -import {Group} from "three"; -import { useRef } from "react"; +import { Group, Color, MeshStandardMaterial } from "three"; +import { useRef, useMemo } from "react"; import { useGLTF } from "@react-three/drei"; import { GLTF } from "three-stdlib"; @@ -24,9 +24,21 @@ export default function TreeBirch({ ...props }: JSX.IntrinsicElements["group"]) { const group = useRef(null!); - const { nodes, materials } = useGLTF( + const { nodes } = useGLTF( "gltf/tree_birch/tree_birch.glb" ) as GLTFResult; + + + const optimizedMaterial = useMemo(() => { + return new MeshStandardMaterial({ + color: new Color(0x228B22), + emissive: new Color(0x006400), + emissiveIntensity: 0.2, + roughness: 0.5, + metalness: 0.1, + }); + }, []); + return ( ); } -useGLTF.preload("/scene-transformed.glb"); +useGLTF.preload("gltf/tree_birch/tree_birch.glb"); diff --git a/src/components/Map/Map.tsx b/src/components/Map/Map.tsx index 73da18c..beb411d 100644 --- a/src/components/Map/Map.tsx +++ b/src/components/Map/Map.tsx @@ -1,7 +1,7 @@ import { Instances, Plane } from '@react-three/drei'; import { useFrame, useThree } from '@react-three/fiber'; import { useLayoutEffect, useRef } from 'react'; -import { Euler, InstancedBufferAttribute, InstancedMesh, Mesh, Object3D, Vector3 } from 'three'; +import { Euler, InstancedBufferAttribute, InstancedMesh, Mesh, Object3D, Vector3, MeshPhongMaterial } from 'three'; import debounce from 'lodash.debounce'; import { getTerrainHeight, getTerrainType } from '../../helpers/terrain'; import { Materials } from '../../materials/materials'; @@ -13,6 +13,13 @@ const RENDER_DISTANCE = 32; const MAP_LENGTH = RENDER_DISTANCE * 2 + 1; const MAP_SIZE = Math.pow(MAP_LENGTH, 2); +const waterMaterial = new MeshPhongMaterial({ + color: 0x006994, + shininess: 60, + transparent: true, + opacity: 0.7, +}); + // initialize three.js objects outside of component to avoid re-creating them on every render => better performance const newPosition = new Vector3(); const direction = new Vector3(); @@ -99,10 +106,13 @@ export const Map = () => { return ( <> + + + {/* TODO: fix type */} - tileShader(shader, MATERIALS.texture_atlas)} defines={{ USE_UV: '' }} /> @@ -114,7 +124,7 @@ export const Map = () => { args={[MAP_LENGTH, MAP_LENGTH]} rotation={waterRotation} position={[0, 0, 0]} - material={MATERIALS.water} + material={waterMaterial} onClick={(e) => e.stopPropagation()} ref={waterRef} receiveShadow diff --git a/src/components/RessourceMenu/RessourceMenu.scss b/src/components/RessourceMenu/RessourceMenu.scss index 49719da..f1d26d2 100644 --- a/src/components/RessourceMenu/RessourceMenu.scss +++ b/src/components/RessourceMenu/RessourceMenu.scss @@ -1,31 +1,206 @@ -.ressource-menu { - margin: 0 auto; - position: absolute; - top: 0%; - left: 12%; +$border-radius: 50px; + +@keyframes fadeIn { + from { + opacity: 0; + filter: blur(4px); + } + to { + opacity: 1; + filter: blur(0); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + filter: blur(0); + } + to { + opacity: 0; + filter: blur(4px); + } +} + +@keyframes subtleBounce { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-1px); + } +} + +@keyframes slideIn { + from { + transform: translate(-50%, -100%); + } + to { + transform: translate(-50%, 0); + } +} + +@keyframes pulse { + 0% { + background-color: rgba(33, 73, 77, 0.702); + } + 50% { + background-color: rgba(33, 100, 77, 0.702); + } + 100% { + background-color: rgba(33, 73, 77, 0.702); + } +} + +@keyframes shake { + 0% { + transform: translateX(0); + } + 25% { + transform: translateX(-5px); + } + 50% { + transform: translateX(5px); + } + 75% { + transform: translateX(-5px); + } + 100% { + transform: translateX(0); + } +} + +@keyframes successAnimation { + 0% { + background-color: rgba(33, 73, 77, 0.702); + } + 50% { + background-color: rgba(33, 150, 77, 0.702); + } + 100% { + background-color: rgba(33, 73, 77, 0.702); + } +} + +@keyframes blink { + 0% { + opacity: 1; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} - background: rgba(211, 197, 122, 0.4); - padding: 4px; +.fade-in { + animation: fadeIn 0.5s cubic-bezier(0.25, 0.1, 0.25, 1) both; + will-change: opacity, transform, filter; +} + +.fade-out { + animation: fadeOut 0.5s cubic-bezier(0.25, 0.1, 0.25, 1) both; + will-change: opacity, transform, filter; +} - font-size: 12px; +.ressource-menu { + margin: 0; + position: fixed; + top: 2cqb; //Temp position for gpu overlay + left: 50cqi; + height: auto; + padding: 8px 16px; + width: fit-content; + background-color: rgba(33, 73, 77, 0.702); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: #e6e6e6; backdrop-filter: saturate(180%) blur(30px); -webkit-backdrop-filter: saturate(180%) blur(30px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + transform: translateX(-50%); + transition: + height 1s ease-in-out, + border-radius 0.4s ease-in-out, + width 1s ease-in-out; + border-radius: 45px; + z-index: 1000; - border-radius: 0 0 8px 8px; + table { + animation: fadeIn 1s ease-out; + width: 100%; + border-collapse: collapse; + white-space: nowrap; - th { - font-size: 25px; + th { + animation: subtleBounce 2s infinite ease-in-out; + font-size: 28px; + text-align: center; + padding: 6px 12px; + line-height: 30px; + } + } - + td { - align-content: left; + text-align: center; + font-size: 12px; + padding: 4px; + } + + button { + position: relative; + height: 20px; + font-size: 10px; + border-radius: 3px; + border-style: hidden; + background: rgba(255, 94, 71, 0.7); + color: wheat; + animation-delay: 2s; + animation: fadeIn 0.6s forwards ease-in-out; + } + + @media (prefers-color-scheme: light) { + background-color: #d3c57a66; + } + + @media (prefers-color-scheme: dark) { + background-color: rgba(33, 73, 77, 0.702); + } + + @media (orientation: landscape) { + th { + font-size: 16px; + } + + height: auto; + + + + button { + display:none; + } + + .landscape-view { + display: contents; + } + + .portrait-view { + display: none; + } } } -.ressource-menu button { - width: 100%; - height: 20px; - font-size: 10px; - border-radius: 3px; - background: rgba(98, 94, 71, 0.4); - color: wheat; + +.ressource-menu-extended { + border-radius: 20px; +} +.ressource-menu-extended-items { + td { + animation: fadeIn 0.6s forwards ease-in-out; + } } diff --git a/src/components/RessourceMenu/RessourceMenu.tsx b/src/components/RessourceMenu/RessourceMenu.tsx index 4ac7e8c..6ec919e 100644 --- a/src/components/RessourceMenu/RessourceMenu.tsx +++ b/src/components/RessourceMenu/RessourceMenu.tsx @@ -1,10 +1,11 @@ import { useStore } from '../../store/store'; - +import classNames from 'classnames'; import './RessourceMenu.scss'; export const RessourceMenu = () => { const ressources = useStore((state) => ({ ...state.ressources })); const buildingOutputs = useStore((state) => state.buildingOutputs); + const selected = useStore((state) => state.selected); const reset = useStore((state) => state.reset); const valueSmoothener = (buildingsOutputValue: number) => Math.round((buildingsOutputValue * 6000) / 100); @@ -18,24 +19,27 @@ export const RessourceMenu = () => { }; return ( -
+
- - - - - - + + + + + - - - - - - - - - - - - + <> + {selected && ( + + + + + + + + + )} + +
💵🪵🪨🍟🧑‍🦱💰🪵🪨🍟🧑‍🦱
- Total + + {Math.floor(ressources.gold)} ({valueSmoothener(buildingOutputs.gold)}) @@ -49,21 +53,27 @@ export const RessourceMenu = () => { {Math.floor(ressources.food)} ({valueSmoothener(buildingOutputs.food)}) {Math.floor(ressources.villager)}
- Remaining + {Math.floor(ressources.villager)} {remainingRessources(valueSmoothener(buildingOutputs.gold), Math.floor(ressources.gold))} {remainingRessources(valueSmoothener(buildingOutputs.wood), Math.floor(ressources.wood))}{remainingRessources(valueSmoothener(buildingOutputs.stone), Math.floor(ressources.stone))}{remainingRessources(valueSmoothener(buildingOutputs.food), Math.floor(ressources.food))}
+ + {remainingRessources(valueSmoothener(buildingOutputs.gold), Math.floor(ressources.gold))} {remainingRessources(valueSmoothener(buildingOutputs.wood), Math.floor(ressources.wood))}{remainingRessources(valueSmoothener(buildingOutputs.stone), Math.floor(ressources.stone))}{remainingRessources(valueSmoothener(buildingOutputs.food), Math.floor(ressources.food))}
+ {selected && ( @@ -71,7 +81,7 @@ export const RessourceMenu = () => { - + )}
); }; diff --git a/src/components/Tile/Tile.tsx b/src/components/Tile/Tile.tsx index 41f2e03..828c9a3 100644 --- a/src/components/Tile/Tile.tsx +++ b/src/components/Tile/Tile.tsx @@ -107,7 +107,7 @@ const TileComponent = () => { color={selected ? noColor : isUnlocked ? lightGray : grayScale} > {hasBuilding && } - + {/* {ref?.current?.position && !hasBuilding && ( )} */} diff --git a/src/components/TilePropertiesMenu/TilePropertiesMenu.scss b/src/components/TilePropertiesMenu/TilePropertiesMenu.scss new file mode 100644 index 0000000..9f0862f --- /dev/null +++ b/src/components/TilePropertiesMenu/TilePropertiesMenu.scss @@ -0,0 +1,285 @@ +:root { + --pulse-color-start: rgba(33, 73, 77, 0.702); + --pulse-color-middle: rgba(33, 100, 77, 0.702); +} + +@media (prefers-color-scheme: light) { + :root { + --pulse-color-start: rgba(200, 200, 200, 0.702); + --pulse-color-middle: rgba(117, 191, 121, 0.702); + } +} + +@media (prefers-color-scheme: dark) { + :root { + --pulse-color-start: rgba(33, 73, 77, 0.702); + --pulse-color-middle: rgba(33, 100, 77, 0.702); + } +} + +@keyframes pulse { + 0% { + background-color: var(--pulse-color-start); + } + 50% { + background-color: var(--pulse-color-middle); + } + 100% { + background-color: var(--pulse-color-start); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + filter: blur(4px); + } + to { + opacity: 1; + filter: blur(0); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + filter: blur(0); + } + to { + opacity: 0; + filter: blur(4px); + } +} + +@keyframes slideIn { + from { + transform: translate(-50%, -100%); + } + to { + transform: translate(-50%, 0); + } +} + +@keyframes shake { + 0% { + transform: translateX(0); + } + 25% { + transform: translateX(-5px); + } + 50% { + transform: translateX(5px); + } + 75% { + transform: translateX(-5px); + } + 100% { + transform: translateX(0); + } +} + +@keyframes successAnimation { + 0% { + background-color: rgba(33, 73, 77, 0.702); + } + 50% { + background-color: rgba(33, 150, 77, 0.702); + } + 100% { + background-color: rgba(33, 73, 77, 0.702); + } +} + +.fade-in { + animation: fadeIn 0.5s cubic-bezier(0.25, 0.1, 0.25, 1) both; + will-change: opacity, transform, filter; +} + +.fade-out { + animation: fadeOut 0.5s cubic-bezier(0.25, 0.1, 0.25, 1) both; + will-change: opacity, transform, filter; +} + +* { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; +} + +.TilePropertiesMenu__button { + position: fixed; + top: 41cqi; + left: 5cqi; + backdrop-filter: saturate(180%) blur(30px); + -webkit-backdrop-filter: saturate(180%) blur(30px); + padding: 10px 10px; + border-radius: 25px; + border-width: 0px; + font-size: 16px; + z-index: 1000; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + + + + @media (prefers-color-scheme: light) { + background-color: #d3c57a66; + } + + @media (prefers-color-scheme: dark) { + background-color: rgba(33, 73, 77, 0.702); + } + + + &:disabled { + animation: fadeOut 0.40s forwards ease-in-out; + transition: background 0.2s ease-in-out; + + &:active { + animation: + shake 0.5s ease; + } + } + + &:not(:disabled):not(.selected) { + animation: pulse 4s ease-in-out infinite; + } +} + +.TilePropertiesMenu__modal { + margin: 0 auto; + position: fixed; + + top: 2cqb; + left: 50cqi; + position: fixed; + transform: translate(-50%, 0); + width: 339px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + height: auto; + min-height: 132px; + max-height: 90vh; + overflow: hidden; + border: 0; + background-color: rgb(33, 73, 77); + backdrop-filter: saturate(180%) blur(30px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + border-radius: 20px; + z-index: 1001; + opacity: 0; + animation: fadeIn 0.25s forwards ease-in-out; + + h3 { + color: beige; + margin-bottom: 3px; + font-size: 13px; + } + + ul { + list-style-type: none; + padding: 0; + margin: 0; + max-height: 50px; + overflow-y: scroll; + color: beige; + flex-grow: 1; + } + + li { + padding: 5px 0; + display: flex; + font-size: 11px; + justify-content: space-between; + align-items: center; + } + + input { + -webkit-autocomplete: off; + -webkit-autocorrect: off; + -webkit-spellcheck: false; + outline: none; + width: calc(100% - 80px); + padding: 4px; + margin-bottom: 10px; + border-radius: 15px; + border: none; + background: rgba(211, 197, 122, 0.4); + background-color: rgba(33, 73, 77, 0.702); + backdrop-filter: saturate(180%) blur(30px); + -webkit-backdrop-filter: saturate(180%) blur(30px); + color: whitesmoke; + font-size: 12px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + transition: + background-color 0.3s ease, + filter 0.3s ease; + + &::placeholder { + color: #ccc; + } + } + + button { + background: rgba(211, 197, 122, 0.4); + background-color: rgba(33, 73, 77, 0.702); + backdrop-filter: saturate(180%) blur(30px); + -webkit-backdrop-filter: saturate(180%) blur(30px); + padding: 5px 5px; + border-radius: 25px; + border-width: 0; + font-size: 10px; + z-index: 1000; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + } +} + +.TilePropertiesMenu__modal-confirm { + position: fixed; + top: 1cqb; + right: 2cqb; + width: 50px; + border-radius: 15px; + border: none; + padding: 10px 15px; + color: rgb(196, 31, 31); + cursor: pointer; + transition: background-color 0.3s ease; + background-color: rgba(0, 255, 26, 0.4); + filter: grayscale(0%); + + &:disabled { + box-shadow: 0 0px 0px rgba(0, 0, 0, 0.1); + background-color: rgb(140, 36, 36); + cursor: not-allowed; + filter: grayscale(50%); + transition: + background-color 0.3s ease-in-out, + filter 0.3s ease-in-out; + + &:active { + animation: shake 0.5s ease; + } + } +} + +@media (orientation: landscape) { + .TilePropertiesMenu__modal { + position: fixed; + top: 1cqi; + left: 50; + max-height: 250px; + width: 300px; + background-color: rgba(33, 73, 77, 0.9); + padding: 10px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + border-radius: 20px; + overflow-y: auto; + z-index: 1001; + } + + .TilePropertiesMenu__button { + top: 7cqi; + left: 5cqi; + } +} diff --git a/src/components/TilePropertiesMenu/TilePropertiesMenu.tsx b/src/components/TilePropertiesMenu/TilePropertiesMenu.tsx new file mode 100644 index 0000000..430a49b --- /dev/null +++ b/src/components/TilePropertiesMenu/TilePropertiesMenu.tsx @@ -0,0 +1,85 @@ +import { useStore } from '../../store/store'; +import { useState } from 'react'; +import './TilePropertiesMenu.scss'; +import { createPortal } from 'react-dom'; +import { useClickOutside } from '../../hooks'; +import { Vector3 } from 'three'; + +export const TilePropertiesMenu = () => { + const [modalOpen, setModalOpen] = useState(false); + const [locationTitle, setLocationTitle] = useState(''); + + const selected = useStore((state) => state.selected); + const modalRef = useClickOutside(() => setModalOpen(false)); + const setCamera = useStore((state) => state.setCamera); + + const locations = useStore((state) => state.locations); + + const saveLocation = useStore((state) => state.saveLocation); + + const handleAdd = (t: string, x: number, y: number) => { + saveLocation(t, x, y); + selected!.object.userData.update(); + setModalOpen(false); + setLocationTitle(''); + }; + + return ( + <> + + + {modalOpen && + createPortal( +
+ setLocationTitle(e.target.value)} + placeholder="Enter location title" + className="TilePropertiesMenu__input" + /> + + +

Saved Locations

+ +
    + {Object.entries(locations).map(([title, { positionX, positionY }]) => ( +
  • + {title}: ({positionX.toFixed(2)}, {positionY.toFixed(2)}) + +
  • + ))} +
+
, + document.body, + )} + + ); +}; + +export default TilePropertiesMenu; diff --git a/src/components/building/buildingFinancials.ts b/src/components/building/buildingFinancials.ts index 0de7395..b76be00 100644 --- a/src/components/building/buildingFinancials.ts +++ b/src/components/building/buildingFinancials.ts @@ -9,27 +9,30 @@ type BuildingRessourcesType = { }; export const SELLFACTOR = 0.5; + +const extractAmount = (cost: { amount: number; emoji: string } | undefined) => cost ? Math.ceil(cost.amount * SELLFACTOR) : 0; + export const BuildingSellBenefits: BuildingRessourcesType = { [BuildingType.Outpost]: { - wood: Math.ceil(getCostsOfBuilding(BuildingType.Outpost).wood! * SELLFACTOR), - gold: Math.ceil(getCostsOfBuilding(BuildingType.Outpost).gold! * SELLFACTOR), + wood: extractAmount(getCostsOfBuilding(BuildingType.Outpost).wood), + gold: extractAmount(getCostsOfBuilding(BuildingType.Outpost).gold), }, [BuildingType.Lumberhut]: { - wood: Math.ceil(getCostsOfBuilding(BuildingType.Lumberhut).wood! * SELLFACTOR), - gold: Math.ceil(getCostsOfBuilding(BuildingType.Lumberhut).gold! * SELLFACTOR), - villager: getCostsOfBuilding(BuildingType.Lumberhut).villager!, + wood: extractAmount(getCostsOfBuilding(BuildingType.Lumberhut).wood), + gold: extractAmount(getCostsOfBuilding(BuildingType.Lumberhut).gold), + villager: getCostsOfBuilding(BuildingType.Lumberhut).villager?.amount!, }, [BuildingType.House]: { - wood: Math.ceil(getCostsOfBuilding(BuildingType.House).wood! * SELLFACTOR), - gold: Math.ceil(getCostsOfBuilding(BuildingType.House).gold! * SELLFACTOR), - villager: getCostsOfBuilding(BuildingType.House).villager!, + wood: extractAmount(getCostsOfBuilding(BuildingType.House).wood), + gold: extractAmount(getCostsOfBuilding(BuildingType.House).gold), + villager: getCostsOfBuilding(BuildingType.House).villager?.amount!, }, [BuildingType.CornField]: { - gold: Math.ceil(getCostsOfBuilding(BuildingType.House).gold! * SELLFACTOR), + gold: extractAmount(getCostsOfBuilding(BuildingType.CornField).gold), }, [BuildingType.StoneQuarry]: { - wood: Math.ceil(getCostsOfBuilding(BuildingType.House).wood! * SELLFACTOR), - gold: Math.ceil(getCostsOfBuilding(BuildingType.House).gold! * SELLFACTOR), - villager: getCostsOfBuilding(BuildingType.House).villager!, + wood: extractAmount(getCostsOfBuilding(BuildingType.StoneQuarry).wood), + gold: extractAmount(getCostsOfBuilding(BuildingType.StoneQuarry).gold), + villager: getCostsOfBuilding(BuildingType.StoneQuarry).villager?.amount!, }, -}; +}; \ No newline at end of file diff --git a/src/components/building/buildings.ts b/src/components/building/buildings.ts index 45a83c9..7a5cfb3 100644 --- a/src/components/building/buildings.ts +++ b/src/components/building/buildings.ts @@ -2,6 +2,14 @@ import { Terrain } from '../../helpers/terrain'; import { Ressources } from '../../store/store'; import { BuildingType } from './Building'; +const EMOJIS = { + wood: '🪵', + gold: '💰', + villager: '🧑‍🦱', + food: '🍟', + stone: '🪨', +}; + export type Building = { type: BuildingType; name: string; @@ -9,11 +17,17 @@ export type Building = { description: string; costs: { // how much of each ressource is consumed when placing the building - [key in Ressources]?: number; + [key in Ressources]?: { + amount: number; + emoji: string; + }; }; output: { // use negative values for costs (i.e. output 'gold: -10 / 60' means the building costs 10 gold per minute) - [key in Ressources]?: number; + [key in Ressources]?: { + amount: number; + emoji: string; + }; }; terrains: Terrain[]; // the types of terrain the building can be built on }; @@ -25,13 +39,13 @@ export const allBuildings: Building[] = [ type: BuildingType.Outpost, name: 'Outpost', icon: '🗼', - description: 'Unlocks new areas for your town to grow.', + description: 'The Outpost serves as a beacon of expansion, allowing your town to extend its borders and explore new territories. It stands tall, symbolizing the ambition and growth of your community. Establishing an Outpost is the first step towards a thriving and expansive settlement.', costs: { - wood: 10, - gold: 600, + wood: { amount: 10, emoji: EMOJIS.wood }, + gold: { amount: 600, emoji: EMOJIS.gold }, }, output: { - gold: -20 / 60, + gold: { amount: -20 / 60, emoji: EMOJIS.gold }, }, terrains: [Terrain.BEACH, Terrain.MEADOW, Terrain.FOREST], }, @@ -39,15 +53,15 @@ export const allBuildings: Building[] = [ type: BuildingType.House, name: 'House', icon: '🛖', - description: 'Adds 4 villagers to your town. Villagers pay taxes but require food.', + description: 'A cozy House that provides shelter for your villagers, adding 4 new members to your growing town. These villagers contribute to the economy by paying taxes, but they also require sustenance. The House is a cornerstone of any thriving community, ensuring the well-being and growth of its inhabitants.', costs: { - wood: 6, - gold: 100, - villager: -4, + wood: { amount: 6, emoji: EMOJIS.wood }, + gold: { amount: 100, emoji: EMOJIS.gold }, + villager: { amount: -4, emoji: EMOJIS.villager }, }, output: { - gold: 12 / 60, - food: -1 / 60, + gold: { amount: 12 / 60, emoji: EMOJIS.gold }, + food: { amount: -1 / 60, emoji: EMOJIS.food }, }, terrains: [Terrain.BEACH, Terrain.MEADOW, Terrain.FOREST], }, @@ -55,15 +69,15 @@ export const allBuildings: Building[] = [ type: BuildingType.Lumberhut, name: 'Lumberhut', icon: '🪚', - description: 'A villager works here to produce wood needed to build new buildings, but costs money to maintain.', + description: 'The Lumberhut is a vital structure where a villager works tirelessly to produce the wood necessary for constructing new buildings. Though it requires gold to maintain, the steady supply of wood it provides is indispensable for the expansion and development of your town.', costs: { - wood: 2, - gold: 10, - villager: 1, + wood: { amount: 2, emoji: EMOJIS.wood }, + gold: { amount: 10, emoji: EMOJIS.gold }, + villager: { amount: 1, emoji: EMOJIS.villager }, }, output: { - wood: 2 / 60, - gold: -6 / 60, + wood: { amount: 2 / 60, emoji: EMOJIS.wood }, + gold: { amount: -6 / 60, emoji: EMOJIS.gold }, }, terrains: [Terrain.FOREST], }, @@ -71,13 +85,13 @@ export const allBuildings: Building[] = [ type: BuildingType.CornField, name: 'Cornfield', icon: '🌽', - description: 'Produces low amounts of food. Costs money to maintain.', + description: 'The Cornfield is a humble yet essential source of food for your villagers. While it incurs maintenance costs, the Cornfield ensures a steady supply of nourishment, supporting the health and productivity of your community. It is a testament to the agricultural prowess of your town.', costs: { - gold: 30, + gold: { amount: 30, emoji: EMOJIS.gold }, }, output: { - gold: -8 / 60, - food: 4 / 60, + gold: { amount: -8 / 60, emoji: EMOJIS.gold }, + food: { amount: 4 / 60, emoji: EMOJIS.food }, }, terrains: [Terrain.MEADOW], }, @@ -85,16 +99,15 @@ export const allBuildings: Building[] = [ type: BuildingType.StoneQuarry, name: 'Quarry', icon: '⛏️', - description: - 'Villagers work here to produces stone needed for more advanced buildings, but it costs money to maintain.', + description: 'The Quarry is a bustling hub where villagers extract stone, a crucial resource for constructing advanced buildings. Despite the costs associated with its operation, the Quarry plays a key role in the architectural and infrastructural advancements of your settlement, paving the way for future growth.', costs: { - wood: 6, - gold: 40, - villager: 4, + wood: { amount: 6, emoji: EMOJIS.wood }, + gold: { amount: 40, emoji: EMOJIS.gold }, + villager: { amount: 4, emoji: EMOJIS.villager }, }, output: { - gold: -12 / 60, - stone: 2 / 60, + gold: { amount: -12 / 60, emoji: EMOJIS.gold }, + stone: { amount: 2 / 60, emoji: EMOJIS.stone }, }, terrains: [Terrain.MOUNTAIN], }, @@ -109,4 +122,4 @@ export const getCostsOfBuilding = (buildingType: BuildingType) => allBuildings.find((building) => building.type === buildingType)!.costs; export const getOutputOfBuilding = (buildingType: BuildingType) => - allBuildings.find((building) => building.type === buildingType)!.output; + allBuildings.find((building) => building.type === buildingType)!.output; \ No newline at end of file diff --git a/src/materials/materials.tsx b/src/materials/materials.tsx index a0278b2..4f13154 100644 --- a/src/materials/materials.tsx +++ b/src/materials/materials.tsx @@ -1,9 +1,9 @@ import { useTexture } from '@react-three/drei'; -import { BoxGeometry, Color, MeshStandardMaterial, NearestFilter } from 'three'; +import { BoxGeometry, Color, LinearFilter, MeshPhysicalMaterial,MeshStandardMaterial } from 'three'; import textureAtlas from '../textures/textureAtlas.jpg'; -export const water = new MeshStandardMaterial({ - color: 'blue', +export const water = new MeshPhysicalMaterial({ + color: 'DeepBlue', emissiveIntensity: 0.2, }); @@ -11,22 +11,28 @@ export const building = new BoxGeometry(0.5, 0.5, 0.5); export const Materials = () => { const [texture_atlas] = useTexture([textureAtlas]); - texture_atlas.minFilter = NearestFilter; - texture_atlas.magFilter = NearestFilter; + texture_atlas.minFilter = LinearFilter; + texture_atlas.magFilter = LinearFilter; - const water = new MeshStandardMaterial({ + const water = new MeshPhysicalMaterial({ color: new Color(0x0055ff), emissive: new Color(0x0055ff), emissiveIntensity: 0.4, transparent: true, - opacity: 0.9, - metalness: 0.7, - roughness: 0.5, - wireframe: false, + opacity: 0.8, + metalness: 0.1, + roughness: 0.1, + clearcoat: 1.0, + clearcoatRoughness: 0.1, + reflectivity: 0.9, }); + + const atlas = new MeshStandardMaterial({ map: texture_atlas, + roughness: 0.5, + metalness: 0.1, }); return { diff --git a/src/store/store.ts b/src/store/store.ts index 9a6ea96..56515a3 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -24,6 +24,7 @@ export type State = { }; unlocked: { [key: number]: { [key: number]: number } }; selected: SelectedObject | undefined; + locations: { [key: string]: { positionX: number; positionY: number } }; camera: | { position: Vector3; @@ -40,6 +41,8 @@ export type Actions = { lock: (x: number, y: number, range: number) => void; unlock: (x: number, y: number, range: number) => void; setSelected: (object: SelectedObject | undefined) => void; + saveLocation: (title: string, x: number, y: number) => void; + removeLocation: (title: string) => void; setCamera: (position: Vector3, lookAt: Vector3) => void; reset: () => void; }; @@ -61,6 +64,7 @@ const initialState: State = { villager: 2, }, unlocked: {}, + locations: {}, selected: undefined, camera: undefined, }; @@ -72,16 +76,21 @@ export const useStore = create()( ...initialState, addBuilding: (x, y, building) => set((state) => { - state.removeRessources(getCostsOfBuilding(building)); + const costs = getCostsOfBuilding(building); + const ressourcesToRemove = Object.fromEntries( + Object.entries(costs).map(([key, value]) => [key, value.amount]) + ); + state.removeRessources(ressourcesToRemove); if (building === BuildingType.Outpost) { state.unlock(x, y, 8); } // add building output - const newBuildingOutputs = state.buildingOutputs; - Object.entries(getOutputOfBuilding(building)).forEach((ressource) => { - newBuildingOutputs[ressource[0] as Ressources] += ressource[1]; + const newBuildingOutputs =state.buildingOutputs; + const output = getOutputOfBuilding(building); + Object.entries(output).forEach(([key, value]) => { + newBuildingOutputs[key as Ressources] += value.amount; }); // add new building @@ -94,30 +103,53 @@ export const useStore = create()( buildingOutputs: newBuildingOutputs, }; }), + // add new saved location + saveLocation: (t, x, y) => + set((state) => { + const newLocationType = state.locations; + newLocationType[t] = { positionX: x, positionY: y }; + return { + locations: newLocationType, + }; + }), + // remove saved location + removeLocation: (t) => + set((state) => { + const newLocationType = state.locations; + delete newLocationType[t]; + return { + locations: newLocationType, + }; + }), removeBuilding: (x, y) => set((state) => { const building = state.buildings[x][y]; - state.addRessources(BuildingSellBenefits[state.buildings[x][y]]); + const sellBenefits = BuildingSellBenefits[building]; + const ressourcesToAdd = Object.fromEntries( + Object.entries(sellBenefits).map(([key, value]) => [key, value]) + ); + state.addRessources(ressourcesToAdd); if (building === BuildingType.Outpost) { state.lock(x, y, 8); } - // TODO: remove output froom state.buildingOutputs + // remove building output const newBuildingOutputs = state.buildingOutputs; - Object.entries(getOutputOfBuilding(building)).forEach((ressource) => { - newBuildingOutputs[ressource[0] as Ressources] -= ressource[1]; + const output = getOutputOfBuilding(building); + Object.entries(output).forEach(([key, value]) => { + newBuildingOutputs[key as Ressources] -= value.amount; }); - const newBuildingType = state.buildings; + const newBuildingType = { ...state.buildings }; delete newBuildingType[x][y]; - return { buildings: newBuildingType }; + return { buildings: newBuildingType, buildingOutputs: newBuildingOutputs }; }), addRessources: (ressourcesToAdd: Partial<{ [key in Ressources]: number }>) => set((state: State) => { const newRessources = state.ressources; - Object.entries(ressourcesToAdd).forEach((ressource) => { - newRessources[ressource[0] as Ressources] += ressource[1]; + Object.entries(ressourcesToAdd).forEach(([key, value]) => { + newRessources[key as Ressources] += value!; }); return { ressources: newRessources, @@ -125,9 +157,9 @@ export const useStore = create()( }), removeRessources: (ressourcesToRemove: Partial<{ [key in Ressources]: number }>) => set((state: State) => { - const newRessources = state.ressources; - Object.entries(ressourcesToRemove).forEach((ressource) => { - newRessources[ressource[0] as Ressources] -= ressource[1]; + const newRessources = state.ressources ; + Object.entries(ressourcesToRemove).forEach(([key, value]) => { + newRessources[key as Ressources] -= value!; }); return { ressources: newRessources, @@ -177,11 +209,11 @@ export const useStore = create()( set(() => { return { camera: { position, lookAt } }; }), - reset: () => set({ ...initialState }), + reset: () => set(initialState), }), { name: 'game-state', }, ), ), -); +); \ No newline at end of file