Compare commits
19 Commits
8875eb75af
...
main
Author | SHA1 | Date | |
---|---|---|---|
0be511c245 | |||
2487076e92 | |||
1f2a71452b | |||
e5af3b8c51 | |||
d1a08d055a | |||
64ca3bedf4 | |||
53b9694b09 | |||
9fbff6d8f2 | |||
a12cfc5a2a | |||
62634a426e | |||
88a9c6507c | |||
c2e5bf92a3 | |||
78c0f52c39 | |||
243c50a1a0 | |||
a624ba1e73 | |||
dd32327383 | |||
2202bd606f | |||
6e3b86df24 | |||
a85a80938b |
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
VITE_FIREBASE_API_KEY=your-api-key
|
||||||
|
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
||||||
|
VITE_FIREBASE_PROJECT_ID=your-project-id
|
||||||
|
VITE_FIREBASE_STORAGE_BUCKET=your-project.appspot.com
|
||||||
|
VITE_FIREBASE_MESSAGING_SENDER_ID=your-sender-id
|
||||||
|
VITE_FIREBASE_APP_ID=your-app-id
|
||||||
|
VITE_FIREBASE_MEASUREMENT_ID=your-measurement-id
|
7
.env.production
Normal file
7
.env.production
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
VITE_FIREBASE_API_KEY=VITE_FIREBASE_API_KEY
|
||||||
|
VITE_FIREBASE_AUTH_DOMAIN=VITE_FIREBASE_AUTH_DOMAIN
|
||||||
|
VITE_FIREBASE_PROJECT_ID=VITE_FIREBASE_PROJECT_ID
|
||||||
|
VITE_FIREBASE_STORAGE_BUCKET=VITE_FIREBASE_STORAGE_BUCKET
|
||||||
|
VITE_FIREBASE_MESSAGING_SENDER_ID=VITE_FIREBASE_MESSAGING_SENDER_ID
|
||||||
|
VITE_FIREBASE_APP_ID=VITE_FIREBASE_APP_ID
|
||||||
|
VITE_FIREBASE_MEASUREMENT_ID=VITE_FIREBASE_MEASUREMENT_ID
|
58
.gitea/workflows/main.yml
Normal file
58
.gitea/workflows/main.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
name: Main Build & Deploy
|
||||||
|
|
||||||
|
on: workflow_dispatch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and Push Docker Image (amd64 and arm64)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with:
|
||||||
|
config-inline: |
|
||||||
|
[registry."git.ivanch.me"]
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: https://github.com/docker/login-action@v3.3.0
|
||||||
|
with:
|
||||||
|
registry: git.ivanch.me
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Docker image and push
|
||||||
|
id: docker_build
|
||||||
|
uses: https://github.com/docker/build-push-action@v6.12.0
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: git.ivanch.me/ivanch/pet-companion/pet-companion:latest
|
||||||
|
platforms: linux/amd64, linux/arm64
|
||||||
|
|
||||||
|
- name: Image digest
|
||||||
|
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||||
|
|
||||||
|
deploy_live:
|
||||||
|
name: Deploy Live
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Recreate container
|
||||||
|
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.LIVE_HOST }}
|
||||||
|
username: ${{ secrets.LIVE_USERNAME }}
|
||||||
|
key: ${{ secrets.LIVE_KEY }}
|
||||||
|
port: ${{ secrets.LIVE_PORT }}
|
||||||
|
script: |
|
||||||
|
cd ${{ secrets.LIVE_PROJECT_DIR }}
|
||||||
|
docker compose down
|
||||||
|
docker compose rm
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.env
|
||||||
|
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY env.sh /docker-entrypoint.d/env.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/env.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
12
env.sh
Normal file
12
env.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
for i in $(env | grep VITE_)
|
||||||
|
do
|
||||||
|
key=$(echo $i | cut -d '=' -f 1)
|
||||||
|
value=$(echo $i | cut -d '=' -f 2-)
|
||||||
|
echo $key=$value
|
||||||
|
# sed All files
|
||||||
|
# find /usr/share/nginx/html -type f -exec sed -i "s|${key}|${value}|g" '{}' +
|
||||||
|
|
||||||
|
# sed JS and CSS only
|
||||||
|
find /usr/share/nginx/html -type f \( -name '*.js' -o -name '*.css' \) -exec sed -i "s|${key}|${value}|g" '{}' +
|
||||||
|
done
|
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Idle Pet</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
10
nginx.conf
Normal file
10
nginx.conf
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
1071
package-lock.json
generated
1071
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"firebase": "^11.3.1",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
|
160
src/App.tsx
160
src/App.tsx
@@ -1,20 +1,30 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import ClassSelection from './components/ClassSelection';
|
import { onAuthStateChanged } from 'firebase/auth';
|
||||||
|
import { auth } from './config/firebase';
|
||||||
import PetDisplay from './components/PetDisplay';
|
import PetDisplay from './components/PetDisplay';
|
||||||
import InteractionMenu from './components/InteractionMenu';
|
import InteractionMenu from './components/InteractionMenu';
|
||||||
import NameModal from './components/modal/NameModal';
|
import PetRegister from './components/PetRegister';
|
||||||
import ConfirmationModal from './components/modal/ConfirmationModal';
|
import AnimatedBackground from './components/AnimatedBackground';
|
||||||
import { Pet, PetClassInfo } from './types/Pet';
|
import AuthForm from './components/auth/AuthForm';
|
||||||
import { fetchPets, createPet } from './services/api/api';
|
import { Pet } from './types/Pet';
|
||||||
|
import { fetchPets } from './services/api/api';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [pet, setPet] = useState<Pet | null>(null);
|
const [pet, setPet] = useState<Pet | null>(null);
|
||||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [showNameModal, setShowNameModal] = useState(false);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [petName, setPetName] = useState('');
|
|
||||||
const [selectedClass, setSelectedClass] = useState<{ key: string; info: PetClassInfo } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||||
|
setIsAuthenticated(!!user);
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
const loadPets = async () => {
|
const loadPets = async () => {
|
||||||
try {
|
try {
|
||||||
const pets = await fetchPets();
|
const pets = await fetchPets();
|
||||||
@@ -27,79 +37,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadPets();
|
loadPets();
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClassSelect = (classKey: string, classInfo: PetClassInfo) => {
|
|
||||||
setSelectedClass({ key: classKey, info: classInfo });
|
|
||||||
setShowConfirmation(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
if (!selectedClass) return;
|
|
||||||
setShowConfirmation(false);
|
|
||||||
setShowNameModal(true);
|
|
||||||
setPetName('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameSubmit = async () => {
|
|
||||||
if (!selectedClass || !petName.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newPet = await createPet({
|
|
||||||
name: petName.trim(),
|
|
||||||
class: selectedClass.key,
|
|
||||||
});
|
|
||||||
setPet(newPet);
|
|
||||||
setShowNameModal(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating pet:', error);
|
|
||||||
}
|
}
|
||||||
};
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const handleFeed = () => {
|
|
||||||
if (!pet) return;
|
|
||||||
const updatedPet = {
|
|
||||||
...pet,
|
|
||||||
stats: {
|
|
||||||
...pet.stats,
|
|
||||||
strength: Math.min(100, pet.stats.strength + 5)
|
|
||||||
},
|
|
||||||
resources: {
|
|
||||||
...pet.resources,
|
|
||||||
food: Math.max(0, pet.resources.food - 10)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
handlePetUpdate(updatedPet);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlay = () => {
|
|
||||||
if (!pet) return;
|
|
||||||
const updatedPet = {
|
|
||||||
...pet,
|
|
||||||
stats: {
|
|
||||||
...pet.stats,
|
|
||||||
charisma: Math.min(100, pet.stats.charisma + 5)
|
|
||||||
},
|
|
||||||
resources: {
|
|
||||||
...pet.resources,
|
|
||||||
wisdom: pet.resources.wisdom + 5
|
|
||||||
}
|
|
||||||
};
|
|
||||||
handlePetUpdate(updatedPet);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSleep = () => {
|
|
||||||
if (!pet) return;
|
|
||||||
const updatedPet = {
|
|
||||||
...pet,
|
|
||||||
stats: {
|
|
||||||
intelligence: Math.min(100, pet.stats.intelligence + 5),
|
|
||||||
strength: Math.min(100, pet.stats.strength + 2),
|
|
||||||
charisma: Math.min(100, pet.stats.charisma + 2)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
handlePetUpdate(updatedPet);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomize = () => {
|
const handleCustomize = () => {
|
||||||
console.log('Customize pet');
|
console.log('Customize pet');
|
||||||
@@ -109,45 +48,52 @@ export default function App() {
|
|||||||
setPet(updatedPet);
|
setPet(updatedPet);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!pet) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white">
|
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||||
{showNameModal && selectedClass ? (
|
<div className="text-white">Loading...</div>
|
||||||
<NameModal
|
|
||||||
selectedClass={selectedClass}
|
|
||||||
petName={petName}
|
|
||||||
setPetName={setPetName}
|
|
||||||
handleNameSubmit={handleNameSubmit}
|
|
||||||
setShowNameModal={setShowNameModal}
|
|
||||||
setSelectedClass={setSelectedClass}
|
|
||||||
/>
|
|
||||||
) : showConfirmation && selectedClass ? (
|
|
||||||
<ConfirmationModal
|
|
||||||
selectedClass={selectedClass}
|
|
||||||
handleConfirm={handleConfirm}
|
|
||||||
setShowConfirmation={setShowConfirmation}
|
|
||||||
setSelectedClass={setSelectedClass}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ClassSelection onSelect={handleClassSelect} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white p-4">
|
<>
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<AuthForm onAuthSuccess={() => setIsAuthenticated(true)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pet) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<PetRegister onPetCreated={setPet} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-10 min-h-screen backdrop-blur-sm bg-gray-900/20 text-white p-4">
|
||||||
<div className="max-w-4xl mx-auto grid gap-8">
|
<div className="max-w-4xl mx-auto grid gap-8">
|
||||||
<PetDisplay pet={pet} />
|
<PetDisplay
|
||||||
|
pet={pet}
|
||||||
|
onPetUpdate={handlePetUpdate} // Add this prop
|
||||||
|
/>
|
||||||
<InteractionMenu
|
<InteractionMenu
|
||||||
pet={pet}
|
pet={pet}
|
||||||
onPetUpdate={handlePetUpdate}
|
onPetUpdate={handlePetUpdate}
|
||||||
onFeed={handleFeed}
|
|
||||||
onPlay={handlePlay}
|
|
||||||
onSleep={handleSleep}
|
|
||||||
onCustomize={handleCustomize}
|
onCustomize={handleCustomize}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
100
src/components/ActionResourceButton.tsx
Normal file
100
src/components/ActionResourceButton.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import { Pet } from '../types/Pet';
|
||||||
|
import Tooltip from './Tooltip';
|
||||||
|
import { isActionActive, formatResourceName, getResourceFromAction } from '../utils/petUtils';
|
||||||
|
|
||||||
|
const colorClassMap = {
|
||||||
|
amber: 'bg-amber-900/30 hover:bg-amber-800/50 border-amber-500/50',
|
||||||
|
emerald: 'bg-emerald-900/30 hover:bg-emerald-800/50 border-emerald-500/50',
|
||||||
|
red: 'bg-red-900/30 hover:bg-red-800/50 border-red-500/50',
|
||||||
|
green: 'bg-green-900/30 hover:bg-green-800/50 border-green-500/50',
|
||||||
|
blue: 'bg-blue-900/30 hover:bg-blue-800/50 border-blue-500/50',
|
||||||
|
purple: 'bg-purple-900/30 hover:bg-purple-800/50 border-purple-500/50',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const activeColorClassMap = {
|
||||||
|
amber: 'bg-amber-700/50 border-amber-400',
|
||||||
|
emerald: 'bg-emerald-700/50 border-emerald-400',
|
||||||
|
red: 'bg-red-700/50 border-red-400',
|
||||||
|
green: 'bg-green-700/50 border-green-400',
|
||||||
|
blue: 'bg-blue-700/50 border-blue-400',
|
||||||
|
purple: 'bg-purple-700/50 border-purple-400',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const getActionVerb = (actionType: 'gather' | 'explore' | 'battle'): string => {
|
||||||
|
const verbs = {
|
||||||
|
gather: 'Gathering',
|
||||||
|
explore: 'Exploring',
|
||||||
|
battle: 'Battling',
|
||||||
|
};
|
||||||
|
return verbs[actionType];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionDescription = (actionType: 'gather' | 'explore' | 'battle'): string => {
|
||||||
|
const descriptions = {
|
||||||
|
gather: 'Send your pet to gather resources<br/>Your pet will collect items over time',
|
||||||
|
explore: 'Send your pet to explore<br/>Your pet might find rare items',
|
||||||
|
battle: 'Send your pet to battle<br/>Your pet will gain experience and items',
|
||||||
|
};
|
||||||
|
return descriptions[actionType];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ButtonColor = keyof typeof colorClassMap;
|
||||||
|
|
||||||
|
interface ActionResourceButtonProps {
|
||||||
|
pet: Pet;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
actionType: 'gather' | 'explore' | 'battle';
|
||||||
|
color: ButtonColor;
|
||||||
|
actionText?: string;
|
||||||
|
onActionClick: () => void;
|
||||||
|
onActionComplete: (updatedPet: Pet) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActionResourceButton({
|
||||||
|
pet,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
actionType,
|
||||||
|
color,
|
||||||
|
actionText,
|
||||||
|
onActionClick
|
||||||
|
}: ActionResourceButtonProps) {
|
||||||
|
const isActive = isActionActive(pet.petGatherAction, actionType);
|
||||||
|
const currentResource = getResourceFromAction(pet.petGatherAction);
|
||||||
|
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (!isActive) return label;
|
||||||
|
|
||||||
|
if (actionType === 'explore' && pet.petGatherAction === 'EXPLORE') {
|
||||||
|
return 'Exploring';
|
||||||
|
}
|
||||||
|
if (actionType === 'battle' && pet.petGatherAction === 'BATTLE') {
|
||||||
|
return 'Battling';
|
||||||
|
}
|
||||||
|
if (currentResource) {
|
||||||
|
return `${getActionVerb(actionType)} ${formatResourceName(currentResource)}`;
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<button
|
||||||
|
onClick={onActionClick}
|
||||||
|
className={`flex items-center justify-center space-x-2
|
||||||
|
${isActive ? activeColorClassMap[color] : colorClassMap[color]}
|
||||||
|
border-2 rounded-lg p-4
|
||||||
|
transition-all duration-300 transform hover:scale-105 w-full`}
|
||||||
|
>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
<span>{getButtonText()}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content={actionText || ''}>
|
||||||
|
{button}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
105
src/components/AnimatedBackground.tsx
Normal file
105
src/components/AnimatedBackground.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export default function AnimatedBackground() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
|
||||||
|
const particles: Particle[] = [];
|
||||||
|
const particleCount = 50;
|
||||||
|
|
||||||
|
class Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
speedX: number;
|
||||||
|
speedY: number;
|
||||||
|
opacity: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.x = Math.random() * canvas.width;
|
||||||
|
this.y = Math.random() * canvas.height;
|
||||||
|
this.size = Math.random() * 3 + 1;
|
||||||
|
this.speedX = Math.random() * 1 - 0.5;
|
||||||
|
this.speedY = Math.random() * 1 - 0.5;
|
||||||
|
this.opacity = Math.random() * 0.5 + 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.x += this.speedX;
|
||||||
|
this.y += this.speedY;
|
||||||
|
|
||||||
|
if (this.x > canvas.width) this.x = 0;
|
||||||
|
if (this.x < 0) this.x = canvas.width;
|
||||||
|
if (this.y > canvas.height) this.y = 0;
|
||||||
|
if (this.y < 0) this.y = canvas.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.fillStyle = `rgba(255, 255, 255, ${this.opacity})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create particles
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
particles.push(new Particle());
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectParticles() {
|
||||||
|
for (let i = 0; i < particles.length; i++) {
|
||||||
|
for (let j = i + 1; j < particles.length; j++) {
|
||||||
|
const dx = particles[i].x - particles[j].x;
|
||||||
|
const dy = particles[i].y - particles[j].y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distance < 100) {
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.strokeStyle = `rgba(255, 255, 255, ${0.2 * (1 - distance/100)})`;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(particles[i].x, particles[i].y);
|
||||||
|
ctx.lineTo(particles[j].x, particles[j].y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
particles.forEach(particle => {
|
||||||
|
particle.update();
|
||||||
|
particle.draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
connectParticles();
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className="fixed top-0 left-0 w-full h-full -z-10" />;
|
||||||
|
}
|
@@ -1,20 +1,23 @@
|
|||||||
import { Resources } from '../types/Pet';
|
|
||||||
import { putPetCollectResources } from '../services/api/api';
|
import { putPetCollectResources } from '../services/api/api';
|
||||||
|
import { PetActionGathered } from '../types/PetAction';
|
||||||
|
import { Pet } from '../types/Pet';
|
||||||
|
|
||||||
interface CollectResourcesButtonProps {
|
interface CollectResourcesButtonProps {
|
||||||
petId: string;
|
petId: string;
|
||||||
resources: Resources;
|
resources: PetActionGathered[];
|
||||||
onCollect: () => void;
|
onCollect: () => void;
|
||||||
|
onPetUpdate: (pet: Pet) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CollectResourcesButton({ petId, resources, onCollect }: CollectResourcesButtonProps) {
|
export default function CollectResourcesButton({ petId, resources, onCollect, onPetUpdate }: CollectResourcesButtonProps) {
|
||||||
const hasResources = Object.values(resources).some(value => value > 0);
|
const hasResources = Object.values(resources).length > 0;
|
||||||
|
|
||||||
if (!hasResources) return null;
|
if (!hasResources) return null;
|
||||||
|
|
||||||
const handleCollect = async () => {
|
const handleCollect = async () => {
|
||||||
try {
|
try {
|
||||||
await putPetCollectResources(petId);
|
const updatedPet = await putPetCollectResources(petId);
|
||||||
|
onPetUpdate(updatedPet);
|
||||||
onCollect();
|
onCollect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to collect resources:', error);
|
console.error('Failed to collect resources:', error);
|
||||||
@@ -24,17 +27,20 @@ export default function CollectResourcesButton({ petId, resources, onCollect }:
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleCollect}
|
onClick={handleCollect}
|
||||||
className="flex items-center justify-center space-x-2
|
className="flex flex-col items-center justify-center
|
||||||
bg-green-900/30 hover:bg-green-800/50
|
bg-green-900/30 hover:bg-green-800/50
|
||||||
border-2 border-green-500/50 rounded-lg p-4
|
border-2 border-green-500/50 rounded-lg p-4
|
||||||
transition-all duration-300 transform hover:scale-105 w-full mt-2"
|
transition-all duration-300 transform hover:scale-105 w-full mt-2"
|
||||||
>
|
>
|
||||||
<span>
|
<span className="font-bold mb-1">Collect:</span>
|
||||||
Collect: {Object.entries(resources)
|
{resources.map((item, index) => (
|
||||||
.filter(([_, value]) => value > 0)
|
<span key={index}>
|
||||||
.map(([key, value]) => `${value} ${key}`)
|
{item.gameItem
|
||||||
.join(', ')}
|
? item.gameItem.name
|
||||||
|
: `${item.resource} x${item.amount}`
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
|
))}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,132 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { FeatherIcon as GatherIcon } from 'lucide-react';
|
|
||||||
import ResourceSelectionModal from './modal/ResourceSelectionModal';
|
|
||||||
import CollectResourcesButton from './CollectResourcesButton';
|
|
||||||
import { Pet, Resources } from '../types/Pet';
|
|
||||||
import { updatePetAction, getPetGatheredResources, putPetCollectResources } from '../services/api/api';
|
|
||||||
import { PetAction } from '../types/PetUpdateActionRequest';
|
|
||||||
import { isGatheringAction, formatResourceName, getResourceFromAction } from '../utils/petUtils';
|
|
||||||
|
|
||||||
interface GatherResourcesButtonProps {
|
|
||||||
pet: Pet;
|
|
||||||
onGatherStart: () => void;
|
|
||||||
onGatherComplete: (updatedPet: Pet) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceToActionMap: Record<string, PetAction> = {
|
|
||||||
wisdom: 'GATHERING_WISDOM',
|
|
||||||
gold: 'GATHERING_GOLD',
|
|
||||||
food: 'GATHERING_FOOD'
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function GatherResourcesButton({ pet, onGatherStart, onGatherComplete }: GatherResourcesButtonProps) {
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [isGathering, setIsGathering] = useState(isGatheringAction(pet.petAction));
|
|
||||||
const [gatheredResources, setGatheredResources] = useState<Resources>({ wisdom: 0, gold: 0, food: 0, junk: 0 });
|
|
||||||
|
|
||||||
// Initialize gathering check if pet is already gathering
|
|
||||||
useEffect(() => {
|
|
||||||
if (isGatheringAction(pet.petAction)) {
|
|
||||||
setIsGathering(true);
|
|
||||||
const resources = getPetGatheredResources(pet.id).then(setGatheredResources);
|
|
||||||
onGatherStart();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let interval: number;
|
|
||||||
|
|
||||||
if (isGathering) {
|
|
||||||
interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resources = await getPetGatheredResources(pet.id);
|
|
||||||
setGatheredResources(resources);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check gathered resources:', error);
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isGathering, pet.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsGathering(isGatheringAction(pet.petAction));
|
|
||||||
}, [pet.petAction]);
|
|
||||||
|
|
||||||
const handleGatherStart = async (resourceType: string) => {
|
|
||||||
if (resourceType === 'stop') {
|
|
||||||
try {
|
|
||||||
await updatePetAction(pet.id, { petActionGather: 'IDLE' });
|
|
||||||
setIsGathering(false);
|
|
||||||
onGatherComplete(pet);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to stop gathering:', error);
|
|
||||||
}
|
|
||||||
setIsModalOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsGathering(true);
|
|
||||||
onGatherStart();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const petAction = resourceToActionMap[resourceType];
|
|
||||||
const updatedPet = await updatePetAction(pet.id, { petActionGather: petAction });
|
|
||||||
onGatherComplete(updatedPet);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to gather resources:', error);
|
|
||||||
} finally {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCollect = async () => {
|
|
||||||
try {
|
|
||||||
const updatedPet = await putPetCollectResources(pet.id);
|
|
||||||
setGatheredResources({ wisdom: 0, gold: 0, food: 0, junk: 0 });
|
|
||||||
onGatherComplete(updatedPet);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to collect resources:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentResource = getResourceFromAction(pet.petAction);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsModalOpen(true)}
|
|
||||||
className={`flex items-center justify-center space-x-2
|
|
||||||
bg-amber-900/30 hover:bg-amber-800/50
|
|
||||||
border-2 border-amber-500/50 rounded-lg p-4
|
|
||||||
transition-all duration-300 transform hover:scale-105 w-full`}
|
|
||||||
>
|
|
||||||
<GatherIcon className="w-6 h-6" />
|
|
||||||
<span>
|
|
||||||
{isGathering && currentResource
|
|
||||||
? `Gathering ${formatResourceName(currentResource)}...`
|
|
||||||
: 'Gather Resources'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<CollectResourcesButton
|
|
||||||
petId={pet.id}
|
|
||||||
resources={gatheredResources}
|
|
||||||
onCollect={handleCollect}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ResourceSelectionModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={() => setIsModalOpen(false)}
|
|
||||||
onGather={handleGatherStart}
|
|
||||||
pet={pet}
|
|
||||||
isGathering={isGathering}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -1,47 +1,216 @@
|
|||||||
import { Pizza, PlayCircle, Moon, Paintbrush } from 'lucide-react';
|
import { Pizza, PlayCircle, Moon, Compass, Sword, FeatherIcon } from 'lucide-react';
|
||||||
import GatherResourcesButton from './GatherResourcesButton';
|
import CollectResourcesButton from './CollectResourcesButton';
|
||||||
import { Pet } from '../types/Pet';
|
import { Pet } from '../types/Pet';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { updatePetAction, getPetGatheredResources } from '../services/api/api';
|
||||||
|
import { PetActionGathered, PetBasicAction, PetGatherAction } from '../types/PetAction';
|
||||||
|
import ActionButton from './button/ActionButton';
|
||||||
|
import ActionResourceButton from './ActionResourceButton';
|
||||||
|
import ResourceSelectionModal from './modal/ResourceSelectionModal';
|
||||||
|
|
||||||
interface InteractionMenuProps {
|
interface InteractionMenuProps {
|
||||||
pet: Pet;
|
pet: Pet;
|
||||||
onPetUpdate: (updatedPet: Pet) => void;
|
onPetUpdate: (updatedPet: Pet) => void;
|
||||||
onFeed: () => void;
|
|
||||||
onPlay: () => void;
|
|
||||||
onSleep: () => void;
|
|
||||||
onCustomize: () => void;
|
onCustomize: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InteractionMenu({ pet, onPetUpdate, onFeed, onPlay, onSleep, onCustomize }: InteractionMenuProps) {
|
export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuProps) {
|
||||||
|
const [gatheredResources, setGatheredResources] = useState<PetActionGathered[]>([]);
|
||||||
|
const [remainingCooldown, setRemainingCooldown] = useState<number | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateCooldown = () => {
|
||||||
|
if (!pet.basicActionCooldown) {
|
||||||
|
setRemainingCooldown(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldownTime = new Date(pet.basicActionCooldown).getTime();
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const remaining = Math.max(0, cooldownTime - now);
|
||||||
|
|
||||||
|
if (remaining === 0) {
|
||||||
|
setRemainingCooldown(null);
|
||||||
|
} else {
|
||||||
|
setRemainingCooldown(remaining);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCooldown();
|
||||||
|
const interval = setInterval(updateCooldown, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [pet.basicActionCooldown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchGatheredResources = async () => {
|
||||||
|
if (pet.petGatherAction === 'IDLE') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resources = await getPetGatheredResources(pet.id);
|
||||||
|
setGatheredResources(resources);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch gathered resources:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchGatheredResources();
|
||||||
|
const interval = setInterval(fetchGatheredResources, 10000); // Poll every 10 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [pet.id, pet.petGatherAction]);
|
||||||
|
|
||||||
|
const formatCooldownTime = (ms: number) => {
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
|
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes} minute${minutes > 1 ? 's' : ''} remaining`;
|
||||||
|
}
|
||||||
|
return `${seconds} second${seconds > 1 ? 's' : ''} remaining`;
|
||||||
|
};
|
||||||
|
|
||||||
const handleGatherComplete = (updatedPet: Pet) => {
|
const handleGatherComplete = (updatedPet: Pet) => {
|
||||||
onPetUpdate(updatedPet);
|
onPetUpdate(updatedPet);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActionButton = ({ icon: Icon, label, onClick, color }: { icon: any; label: string; onClick: () => void; color: string }) => (
|
function performBasicAction(basicAction: PetBasicAction): () => void {
|
||||||
<button
|
return async () => {
|
||||||
onClick={onClick}
|
try {
|
||||||
className={`flex items-center justify-center space-x-2 bg-${color}-900/30
|
const updatedPet = await updatePetAction(pet.id, { basicAction: basicAction });
|
||||||
hover:bg-${color}-800/50 border-2 border-${color}-500/50 rounded-lg p-4
|
onPetUpdate(updatedPet);
|
||||||
transition-all duration-300 transform hover:scale-105 w-full`}
|
} catch (error) {
|
||||||
>
|
console.error('Failed to perform basic action:', error);
|
||||||
<Icon className="w-6 h-6" />
|
}
|
||||||
<span>{label}</span>
|
};
|
||||||
</button>
|
}
|
||||||
);
|
|
||||||
|
const handleActionStart = async (actionType: 'gather' | 'explore' | 'battle') => {
|
||||||
|
if (actionType === 'gather') {
|
||||||
|
setIsModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const action: PetGatherAction = actionType === 'explore' ? 'EXPLORE' : 'BATTLE';
|
||||||
|
const updatedPet = await updatePetAction(pet.id, { gatherAction: action });
|
||||||
|
onPetUpdate(updatedPet);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start action:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResourceSelect = async (resourceType: string) => {
|
||||||
|
if (resourceType === 'stop') {
|
||||||
|
try {
|
||||||
|
const updatedPet = await updatePetAction(pet.id, { gatherAction: 'IDLE' });
|
||||||
|
onPetUpdate(updatedPet);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stop action:', error);
|
||||||
|
}
|
||||||
|
setIsModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const action: PetGatherAction = `GATHERING_${resourceType.toUpperCase()}` as PetGatherAction;
|
||||||
|
const updatedPet = await updatePetAction(pet.id, { gatherAction: action });
|
||||||
|
onPetUpdate(updatedPet);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start gathering:', error);
|
||||||
|
} finally {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCollect = () => {
|
||||||
|
setGatheredResources([]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 bg-gray-900 p-6 rounded-xl shadow-xl">
|
||||||
<ActionButton icon={Pizza} label="Feed" onClick={onFeed} color="green" />
|
{remainingCooldown !== null && (
|
||||||
<ActionButton icon={PlayCircle} label="Play" onClick={onPlay} color="blue" />
|
<div className="col-span-2 md:col-span-3 text-center p-2 bg-yellow-900/30 border-2 border-yellow-500/50 rounded-lg">
|
||||||
<ActionButton icon={Moon} label="Sleep" onClick={onSleep} color="purple" />
|
<span className="text-yellow-200">Cooldown: {formatCooldownTime(remainingCooldown)}</span>
|
||||||
{/* <ActionButton icon={Paintbrush} label="Customize (coming soon...)" onClick={onCustomize} color="pink" /> */}
|
</div>
|
||||||
<div className="col-span-2 md:col-span-3">
|
)}
|
||||||
<GatherResourcesButton
|
|
||||||
|
<ActionButton
|
||||||
|
icon={Pizza}
|
||||||
|
label="Feed"
|
||||||
|
onClick={performBasicAction('FEED')}
|
||||||
|
color="green"
|
||||||
|
disabled={remainingCooldown !== null}
|
||||||
|
actionText="Feed your pet<br/>+1 Strengh (up to max)<br/>+5 Health<br/>-1 Food"
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon={PlayCircle}
|
||||||
|
label="Play"
|
||||||
|
onClick={performBasicAction('PLAY')}
|
||||||
|
color="blue"
|
||||||
|
disabled={remainingCooldown !== null}
|
||||||
|
actionText="Play with your pet<br/>+1 Charisma (up to max)<br/>-1 Junk"
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon={Moon}
|
||||||
|
label="Sleep"
|
||||||
|
onClick={performBasicAction('SLEEP')}
|
||||||
|
color="purple"
|
||||||
|
disabled={remainingCooldown !== null}
|
||||||
|
actionText="Put your pet to sleep<br/>+1 Intelligence (up to max)<br/>+1 Strengh (up to max)<br/>+15 Health"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="col-span-2 md:col-span-3 grid grid-cols-3 gap-4">
|
||||||
|
<ActionResourceButton
|
||||||
pet={pet}
|
pet={pet}
|
||||||
onGatherStart={() => console.log('Gathering started')}
|
icon={FeatherIcon}
|
||||||
onGatherComplete={handleGatherComplete}
|
label="Gather"
|
||||||
|
actionType="gather"
|
||||||
|
color="amber"
|
||||||
|
onActionClick={() => handleActionStart('gather')}
|
||||||
|
onActionComplete={handleGatherComplete}
|
||||||
|
actionText='Send your pet to gather resources<br/>Your pet will collect items over time'
|
||||||
|
/>
|
||||||
|
<ActionResourceButton
|
||||||
|
pet={pet}
|
||||||
|
icon={Compass}
|
||||||
|
label="Explore"
|
||||||
|
actionType="explore"
|
||||||
|
color="emerald"
|
||||||
|
onActionClick={() => handleActionStart('explore')}
|
||||||
|
onActionComplete={handleGatherComplete}
|
||||||
|
actionText='Send your pet to explore based on their strength<br/>Your pet might find wisdom and rare items but also may lose health'
|
||||||
|
/>
|
||||||
|
<ActionResourceButton
|
||||||
|
pet={pet}
|
||||||
|
icon={Sword}
|
||||||
|
label="Battle"
|
||||||
|
actionType="battle"
|
||||||
|
color="red"
|
||||||
|
onActionClick={() => handleActionStart('battle')}
|
||||||
|
onActionComplete={handleGatherComplete}
|
||||||
|
actionText='Send your pet to battle based on their strength<br/>Your pet will gain experience and items, but also may lose health'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 md:col-span-3">
|
||||||
|
<CollectResourcesButton
|
||||||
|
petId={pet.id}
|
||||||
|
resources={gatheredResources}
|
||||||
|
onCollect={handleCollect}
|
||||||
|
onPetUpdate={onPetUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResourceSelectionModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onGather={handleResourceSelect}
|
||||||
|
pet={pet}
|
||||||
|
isGathering={pet.petGatherAction !== 'IDLE'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
@@ -1,22 +1,27 @@
|
|||||||
import { Pet } from '../types/Pet';
|
import { Pet } from '../types/Pet';
|
||||||
import { Brain, Dumbbell, Heart, Sparkles, Coins, Pizza, Trash2 } from 'lucide-react';
|
import { Brain, Dumbbell, Smile, Sparkles, Coins, Pizza, Trash2, Trophy, Heart, Star } from 'lucide-react';
|
||||||
import { PET_CLASSES } from '../data/petClasses';
|
import { PET_CLASSES } from '../data/petClasses';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import InventoryModal from './modal/InventoryModal';
|
||||||
|
import SkillTreeModal from './modal/SkillTreeModal';
|
||||||
|
|
||||||
interface PetDisplayProps {
|
interface PetDisplayProps {
|
||||||
pet: Pet;
|
pet: Pet;
|
||||||
|
onPetUpdate: (updatedPet: Pet) => void; // Add this prop
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PetDisplay({ pet }: PetDisplayProps) {
|
export default function PetDisplay({ pet, onPetUpdate }: PetDisplayProps) {
|
||||||
const StatBar = ({ value, label, icon: Icon }: { value: number; label: string; icon: any }) => (
|
const StatBar = ({ value, maxValue, label, icon: Icon }:
|
||||||
<div className="flex items-center space-x-2">
|
{ value: number; maxValue: number; label: string; icon: any; }) => (
|
||||||
|
<div className="flex items-center space-x-2" title={label}>
|
||||||
<Icon className="w-5 h-5" />
|
<Icon className="w-5 h-5" />
|
||||||
<div className="flex-1 bg-gray-700 rounded-full h-4">
|
<div className="flex-1 bg-gray-700 rounded-full h-4">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-500 rounded-full h-4"
|
className={`bg-blue-500 rounded-full h-4`}
|
||||||
style={{ width: `${value}%` }}
|
style={{ width: `${(value / maxValue) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm">{value}%</span>
|
<span className="text-sm font-medium">{value}/{maxValue}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -28,28 +33,96 @@ export default function PetDisplay({ pet }: PetDisplayProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [showInventoryModal, setShowInventoryModal] = useState(false);
|
||||||
|
const [showSkillTreeModal, setShowSkillTreeModal] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 p-6 rounded-xl">
|
<div className="bg-gray-900 p-6 rounded-xl shadow-xl">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="text-8xl mb-4">{PET_CLASSES[pet.class].emoji}</div>
|
<div className="text-8xl mb-4">{PET_CLASSES[pet.class].emoji}</div>
|
||||||
<h2 className="text-2xl font-bold">{pet.name}</h2>
|
<h2 className="text-2xl font-bold mb-2">
|
||||||
|
<span className="bg-gradient-to-r from-blue-500 to-purple-500 text-transparent bg-clip-text">
|
||||||
|
{pet.name}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
|
<Trophy className="w-5 h-5 text-yellow-500" />
|
||||||
|
<span className="text-lg font-semibold">Level {pet.level}</span>
|
||||||
|
</div>
|
||||||
<p className={`text-${PET_CLASSES[pet.class].color}-400`}>
|
<p className={`text-${PET_CLASSES[pet.class].color}-400`}>
|
||||||
{PET_CLASSES[pet.class].name}
|
{PET_CLASSES[pet.class].name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
<div className="space-y-4 mb-6">
|
||||||
<StatBar value={pet.stats.intelligence} label="Intelligence" icon={Brain} />
|
<div className="space-y-4 mb-6">
|
||||||
<StatBar value={pet.stats.strength} label="Strength" icon={Dumbbell} />
|
<StatBar
|
||||||
<StatBar value={pet.stats.charisma} label="Charisma" icon={Heart} />
|
value={pet.health}
|
||||||
|
maxValue={pet.maxHealth}
|
||||||
|
label="Health"
|
||||||
|
icon={Heart}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<StatBar
|
||||||
|
value={pet.stats.intelligence}
|
||||||
|
maxValue={pet.stats.maxIntelligence}
|
||||||
|
label="Intelligence"
|
||||||
|
icon={Brain}
|
||||||
|
/>
|
||||||
|
<StatBar
|
||||||
|
value={pet.stats.strength}
|
||||||
|
maxValue={pet.stats.maxStrength}
|
||||||
|
label="Strength"
|
||||||
|
icon={Dumbbell}
|
||||||
|
/>
|
||||||
|
<StatBar
|
||||||
|
value={pet.stats.charisma}
|
||||||
|
maxValue={pet.stats.maxCharisma}
|
||||||
|
label="Charisma"
|
||||||
|
icon={Smile}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="flex items-start mb-4 space-x-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 flex-1">
|
||||||
<ResourceCounter value={pet.resources.wisdom} label="Wisdom" icon={Sparkles} />
|
<ResourceCounter value={pet.resources.wisdom} label="Wisdom" icon={Sparkles} />
|
||||||
<ResourceCounter value={pet.resources.gold} label="Gold" icon={Coins} />
|
<ResourceCounter value={pet.resources.gold} label="Gold" icon={Coins} />
|
||||||
<ResourceCounter value={pet.resources.food} label="Food" icon={Pizza} />
|
<ResourceCounter value={pet.resources.food} label="Food" icon={Pizza} />
|
||||||
<ResourceCounter value={pet.resources.junk} label="Junk" icon={Trash2} />
|
<ResourceCounter value={pet.resources.junk} label="Junk" icon={Trash2} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInventoryModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Inventory
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSkillTreeModal(true)}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Skill Tree
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showInventoryModal && (
|
||||||
|
<InventoryModal
|
||||||
|
inventory={pet.inventory}
|
||||||
|
petId={pet.id}
|
||||||
|
onClose={() => setShowInventoryModal(false)}
|
||||||
|
onPetUpdate={onPetUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showSkillTreeModal && (
|
||||||
|
<SkillTreeModal
|
||||||
|
petId={pet.id}
|
||||||
|
petResources={pet.resources}
|
||||||
|
onPetUpdate={onPetUpdate}
|
||||||
|
onClose={() => setShowSkillTreeModal(false)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
67
src/components/PetRegister.tsx
Normal file
67
src/components/PetRegister.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import ClassSelection from './ClassSelection';
|
||||||
|
import NameModal from './modal/NameModal';
|
||||||
|
import ConfirmationModal from './modal/ConfirmationModal';
|
||||||
|
import { PetClassInfo } from '../types/Pet';
|
||||||
|
import { createPet } from '../services/api/api';
|
||||||
|
|
||||||
|
interface PetRegisterProps {
|
||||||
|
onPetCreated: (newPet: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PetRegister({ onPetCreated }: PetRegisterProps) {
|
||||||
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||||
|
const [showNameModal, setShowNameModal] = useState(false);
|
||||||
|
const [petName, setPetName] = useState('');
|
||||||
|
const [selectedClass, setSelectedClass] = useState<{ key: string; info: PetClassInfo } | null>(null);
|
||||||
|
|
||||||
|
const handleClassSelect = (classKey: string, classInfo: PetClassInfo) => {
|
||||||
|
setSelectedClass({ key: classKey, info: classInfo });
|
||||||
|
setShowConfirmation(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!selectedClass) return;
|
||||||
|
setShowConfirmation(false);
|
||||||
|
setShowNameModal(true);
|
||||||
|
setPetName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameSubmit = async () => {
|
||||||
|
if (!selectedClass || !petName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newPet = await createPet({
|
||||||
|
name: petName.trim(),
|
||||||
|
class: selectedClass.key,
|
||||||
|
});
|
||||||
|
onPetCreated(newPet);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating pet:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 text-white">
|
||||||
|
{showNameModal && selectedClass ? (
|
||||||
|
<NameModal
|
||||||
|
selectedClass={selectedClass}
|
||||||
|
petName={petName}
|
||||||
|
setPetName={setPetName}
|
||||||
|
handleNameSubmit={handleNameSubmit}
|
||||||
|
setShowNameModal={setShowNameModal}
|
||||||
|
setSelectedClass={setSelectedClass}
|
||||||
|
/>
|
||||||
|
) : showConfirmation && selectedClass ? (
|
||||||
|
<ConfirmationModal
|
||||||
|
selectedClass={selectedClass}
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
setShowConfirmation={setShowConfirmation}
|
||||||
|
setSelectedClass={setSelectedClass}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ClassSelection onSelect={handleClassSelect} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
20
src/components/Tooltip.tsx
Normal file
20
src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
interface TooltipProps {
|
||||||
|
content: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tooltip({ content, children }: TooltipProps) {
|
||||||
|
return (
|
||||||
|
<div className="group relative inline-block w-full">
|
||||||
|
{children}
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300
|
||||||
|
absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2
|
||||||
|
bg-gray-900 text-white text-sm rounded-lg shadow-lg whitespace-nowrap
|
||||||
|
border border-gray-700 pointer-events-none z-50">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||||||
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2
|
||||||
|
border-4 border-transparent border-t-gray-900" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
129
src/components/auth/AuthForm.tsx
Normal file
129
src/components/auth/AuthForm.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { login, register, signInWithGoogle } from '../../services/auth/auth';
|
||||||
|
|
||||||
|
interface AuthFormProps {
|
||||||
|
onAuthSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthForm({ onAuthSuccess }: AuthFormProps) {
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isLogin) {
|
||||||
|
await login(email, password);
|
||||||
|
} else {
|
||||||
|
await register(email, password);
|
||||||
|
}
|
||||||
|
onAuthSuccess();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleSignIn = async () => {
|
||||||
|
try {
|
||||||
|
await signInWithGoogle();
|
||||||
|
onAuthSuccess();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||||
|
<div className="bg-gray-800 p-8 rounded-xl shadow-xl w-96">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-6 text-center">
|
||||||
|
{isLogin ? 'Login' : 'Register'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500 text-white p-3 rounded mb-4 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full p-3 rounded bg-gray-700 text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full p-3 rounded bg-gray-700 text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white p-3 rounded"
|
||||||
|
>
|
||||||
|
{isLogin ? 'Login' : 'Register'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-gray-800 text-gray-400">Or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoogleSignIn}
|
||||||
|
className="mt-4 w-full flex items-center justify-center gap-2 bg-white hover:bg-gray-100 text-gray-900 font-semibold p-3 rounded"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsLogin(!isLogin)}
|
||||||
|
className="w-full text-blue-400 mt-4 text-sm"
|
||||||
|
>
|
||||||
|
{isLogin ? 'Need an account? Register' : 'Have an account? Login'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
37
src/components/button/ActionButton.tsx
Normal file
37
src/components/button/ActionButton.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import Tooltip from '../Tooltip';
|
||||||
|
|
||||||
|
interface ActionButtonProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
color: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
actionText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActionButton({ icon: Icon, label, onClick, color, disabled, actionText }: ActionButtonProps) {
|
||||||
|
const button = (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex items-center justify-center space-x-2 bg-${color}-900/30
|
||||||
|
hover:bg-${color}-800/50 border-2 border-${color}-500/50 rounded-lg p-4
|
||||||
|
transition-all duration-300 transform hover:scale-105 w-full
|
||||||
|
${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!actionText) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content={actionText}>
|
||||||
|
{button}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
201
src/components/modal/InventoryModal.tsx
Normal file
201
src/components/modal/InventoryModal.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Inventory, InvItemInteraction, Pet } from '../../types/Pet';
|
||||||
|
import { putPetItemInteract, getItemIcon, getItemInfo } from '../../services/api/api';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { GameItem, ItemRarity } from '../../types/GameItem';
|
||||||
|
|
||||||
|
interface InventoryModalProps {
|
||||||
|
inventory: Inventory;
|
||||||
|
petId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onPetUpdate: (updatedPet: Pet) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InventoryModal({ inventory, petId, onClose, onPetUpdate }: InventoryModalProps) {
|
||||||
|
const capacity = inventory.capacity;
|
||||||
|
const items = inventory.items;
|
||||||
|
const gridSlots = Array.from({ length: capacity }, (_, i) => items[i]);
|
||||||
|
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
|
||||||
|
const [itemIcons, setItemIcons] = useState<Map<number, string>>(new Map());
|
||||||
|
const [loadingIcons, setLoadingIcons] = useState<Set<number>>(new Set());
|
||||||
|
const [selectedItemDetails, setSelectedItemDetails] = useState<GameItem | null>(null);
|
||||||
|
const [loadingDetails, setLoadingDetails] = useState(false);
|
||||||
|
|
||||||
|
const rarityColors = {
|
||||||
|
[ItemRarity.Common]: 'text-gray-200',
|
||||||
|
[ItemRarity.Uncommon]: 'text-green-400',
|
||||||
|
[ItemRarity.Rare]: 'text-blue-400',
|
||||||
|
[ItemRarity.Legendary]: 'text-purple-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchItemIcons = async () => {
|
||||||
|
const newIcons = new Map<number, string>();
|
||||||
|
const loadingItems = new Set<number>();
|
||||||
|
|
||||||
|
for (const itemId of items) {
|
||||||
|
if (itemId !== undefined && !itemIcons.has(itemId)) {
|
||||||
|
loadingItems.add(itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoadingIcons(loadingItems);
|
||||||
|
|
||||||
|
for (const itemId of loadingItems) {
|
||||||
|
if (itemId !== undefined && !itemIcons.has(itemId)) {
|
||||||
|
try {
|
||||||
|
const blob = await getItemIcon(itemId);
|
||||||
|
const imageUrl = URL.createObjectURL(blob);
|
||||||
|
newIcons.set(itemId, imageUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load icon for item ${itemId}:`, error);
|
||||||
|
} finally {
|
||||||
|
loadingItems.delete(itemId);
|
||||||
|
setLoadingIcons(new Set(loadingItems));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setItemIcons(new Map([...itemIcons, ...newIcons]));
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchItemIcons();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup object URLs on unmount
|
||||||
|
itemIcons.forEach(url => URL.revokeObjectURL(url));
|
||||||
|
};
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const handleItemClick = async (index: number) => {
|
||||||
|
if (selectedItemIndex === index || gridSlots[index] === undefined) {
|
||||||
|
setSelectedItemIndex(null);
|
||||||
|
setSelectedItemDetails(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedItemIndex(index);
|
||||||
|
const itemId = gridSlots[index];
|
||||||
|
|
||||||
|
if (itemId !== undefined) {
|
||||||
|
setLoadingDetails(true);
|
||||||
|
try {
|
||||||
|
const details = await getItemInfo(itemId);
|
||||||
|
setSelectedItemDetails(details);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load item details:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingDetails(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInteraction = async (interaction: InvItemInteraction) => {
|
||||||
|
if (selectedItemIndex === null || gridSlots[selectedItemIndex] === undefined) return;
|
||||||
|
try {
|
||||||
|
const updatedPet = await putPetItemInteract(petId, gridSlots[selectedItemIndex], interaction);
|
||||||
|
onPetUpdate(updatedPet);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Item interaction failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-gray-800 p-6 rounded-xl max-w-2xl w-full">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-2xl font-bold">Inventory</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 grid-rows-4 gap-4">
|
||||||
|
{gridSlots.map((itemId, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleItemClick(index)}
|
||||||
|
className={`border rounded p-2 flex items-center justify-center h-24 w-24
|
||||||
|
${itemId !== undefined ? 'bg-gray-700 cursor-pointer' : 'bg-gray-800 text-gray-500'}
|
||||||
|
${selectedItemIndex === index ? 'border-blue-500 animate-pulse' : 'border-gray-600'}`}
|
||||||
|
>
|
||||||
|
{itemId !== undefined ? (
|
||||||
|
loadingIcons.has(itemId) ? (
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={itemIcons.get(itemId)}
|
||||||
|
alt={`Item ${itemId}`}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">Empty</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedItemIndex !== null && (
|
||||||
|
<div className="mt-4 mb-4 p-4 bg-gray-700 rounded">
|
||||||
|
{loadingDetails ? (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : selectedItemDetails ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">
|
||||||
|
{selectedItemDetails.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<span className={rarityColors[selectedItemDetails.rarity]}>
|
||||||
|
{selectedItemDetails.rarity}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-300">•</span>
|
||||||
|
<span className="text-gray-200">{selectedItemDetails.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 text-sm space-y-1">
|
||||||
|
{selectedItemDetails.description.split(';').map((line, index) => (
|
||||||
|
<p key={index}>{line.trim()}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||||
|
<button
|
||||||
|
disabled={selectedItemIndex === null}
|
||||||
|
onClick={() => handleInteraction('USE')}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Use
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={selectedItemIndex === null}
|
||||||
|
onClick={() => handleInteraction('DROP')}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Drop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={selectedItemIndex === null}
|
||||||
|
onClick={() => handleInteraction('EQUIP')}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Equip
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={selectedItemIndex === null}
|
||||||
|
onClick={() => handleInteraction('UNEQUIP')}
|
||||||
|
className="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Unequip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Brain, Coins, Pizza, X, Loader2, StopCircle } from 'lucide-react';
|
import { Brain, Coins, Pizza, X, StopCircle } from 'lucide-react';
|
||||||
import { Pet } from '../../types/Pet';
|
import { Pet } from '../../types/Pet';
|
||||||
import { formatResourceName, getResourceFromAction } from '../../utils/petUtils';
|
import { formatResourceName, getResourceFromAction } from '../../utils/petUtils';
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export default function ResourceSelectionModal({
|
|||||||
return Math.floor(baseStat * multiplier);
|
return Math.floor(baseStat * multiplier);
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentResource = getResourceFromAction(pet.petAction);
|
const currentResource = getResourceFromAction(pet.petGatherAction);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||||
|
22
src/components/modal/SkillTreeModal.module.css
Normal file
22
src/components/modal/SkillTreeModal.module.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
.customScroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #4B5563 #1F2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customScroll::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customScroll::-webkit-scrollbar-track {
|
||||||
|
background: #1F2937;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customScroll::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #4B5563;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customScroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #6B7280;
|
||||||
|
}
|
213
src/components/modal/SkillTreeModal.tsx
Normal file
213
src/components/modal/SkillTreeModal.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Pet, Resources } from '../../types/Pet';
|
||||||
|
import { Skill, PetSkill, SkillType } from '../../types/Skills';
|
||||||
|
import { fetchPets, getAllSkills, getPetSkills, postAllocatePetSkill } from '../../services/api/api';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import styles from './SkillTreeModal.module.css';
|
||||||
|
|
||||||
|
interface SkillTreeModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
petId: string;
|
||||||
|
petResources: Resources;
|
||||||
|
onPetUpdate: (updatedPet: Pet) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkillTreeModal({ onClose, petId, petResources, onPetUpdate }: SkillTreeModalProps) {
|
||||||
|
const [skills, setSkills] = useState<Skill[]>([]);
|
||||||
|
const [petSkills, setPetSkills] = useState<PetSkill[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [allocating, setAllocating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [allSkills, ownedSkills] = await Promise.all([
|
||||||
|
getAllSkills(),
|
||||||
|
getPetSkills(petId)
|
||||||
|
]);
|
||||||
|
setSkills(allSkills);
|
||||||
|
setPetSkills(ownedSkills);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching skills:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [petId]);
|
||||||
|
|
||||||
|
const handleAllocateSkill = async (skillId: number) => {
|
||||||
|
try {
|
||||||
|
setAllocating(true);
|
||||||
|
const updatedPetSkill = await postAllocatePetSkill(petId, skillId);
|
||||||
|
setPetSkills([...petSkills, updatedPetSkill]);
|
||||||
|
// Refresh pet data to update skill points
|
||||||
|
const updatedPet = await fetchPets();
|
||||||
|
onPetUpdate(updatedPet[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error allocating skill:', error);
|
||||||
|
} finally {
|
||||||
|
setAllocating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSkillTier = (skillId: number) => {
|
||||||
|
return petSkills.find(ps => ps.skillId === skillId)?.currentTier;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canAllocateSkill = (skill: Skill) => {
|
||||||
|
// check if all required skills are owned, and has all the required resources
|
||||||
|
const requiredSkills = skills.filter(s => skill.skillsIdRequired?.includes(s.id));
|
||||||
|
const requiredSkillIds = requiredSkills.map(s => s.id);
|
||||||
|
const ownedRequiredSkills = petSkills.filter(ps => requiredSkillIds.includes(ps.skillId));
|
||||||
|
const hasAllRequiredSkills = requiredSkills.length === ownedRequiredSkills.length;
|
||||||
|
|
||||||
|
const hasAllResources = skill.skillRequirements.every(req => {
|
||||||
|
const resourceValue = petResources[req.resource.toLowerCase() as keyof Resources];
|
||||||
|
return resourceValue >= req.cost;
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasAllRequiredSkills && hasAllResources;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRequiredSkillNames = (skillId: number[] | null) => {
|
||||||
|
if (!skillId) return null;
|
||||||
|
return skills.filter(s => skillId.includes(s.id)).map(s => s.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNextTierEffect = (currentTier?: string) => {
|
||||||
|
if (!currentTier) return 'I';
|
||||||
|
if (currentTier === 'I') return 'II';
|
||||||
|
if (currentTier === 'II') return 'III';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSkillNode = (skill: Skill) => {
|
||||||
|
const owned = petSkills.some(ps => ps.skillId === skill.id);
|
||||||
|
const tier = getSkillTier(skill.id);
|
||||||
|
const isMaxTier = tier === 'III';
|
||||||
|
const canAllocate = !isMaxTier && canAllocateSkill(skill);
|
||||||
|
const nextTierEffect = getNextTierEffect(tier);
|
||||||
|
const requiredSkillNames = getRequiredSkillNames(skill.skillsIdRequired);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={skill.id}
|
||||||
|
className={`
|
||||||
|
relative p-4 rounded-lg border-2 flex flex-col
|
||||||
|
${isMaxTier ? 'border-green-500 bg-gray-700' :
|
||||||
|
owned ? 'border-blue-500 bg-gray-700' : 'border-gray-600 bg-gray-800'}
|
||||||
|
transition-colors
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex-grow">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span>{skill.icon}</span>
|
||||||
|
<h3 className="font-bold">{skill.name}</h3>
|
||||||
|
{tier && (
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${isMaxTier ? 'bg-green-600' : 'bg-blue-600'}`}>
|
||||||
|
Tier {tier}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-300 mb-2">{skill.description}</p>
|
||||||
|
|
||||||
|
<div className="text-sm space-y-2 mb-3">
|
||||||
|
{!isMaxTier && nextTierEffect && (
|
||||||
|
<div className="text-blue-300">
|
||||||
|
Next: Tier {nextTierEffect}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isMaxTier && nextTierEffect && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-gray-400 font-medium">Requirements:</div>
|
||||||
|
{requiredSkillNames && requiredSkillNames.map((name, idx) => (
|
||||||
|
<div key={idx} className={`text-xs ${canAllocate ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
• Requires: {name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{skill.skillRequirements.map((req, idx) => (
|
||||||
|
<div key={idx} className={`text-xs ${petResources[req.resource.toLowerCase() as keyof Resources] >= req.cost
|
||||||
|
? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
• {req.resource}: {req.cost}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMaxTier ? (
|
||||||
|
<div className="mt-auto px-3 py-1 rounded text-sm bg-green-600 text-center">
|
||||||
|
Mastered
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAllocateSkill(skill.id)}
|
||||||
|
disabled={!canAllocate || allocating}
|
||||||
|
className={`
|
||||||
|
mt-auto px-3 py-1 rounded text-sm
|
||||||
|
${canAllocate
|
||||||
|
? owned
|
||||||
|
? 'bg-blue-600 hover:bg-blue-700'
|
||||||
|
: 'bg-green-600 hover:bg-green-700'
|
||||||
|
: 'bg-gray-600 cursor-not-allowed'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{allocating ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
owned ? 'Upgrade' : 'Learn'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSkillTree = () => {
|
||||||
|
const groundSkills = skills.filter(s => s.type === 'GROUND');
|
||||||
|
const grandSkills = skills.filter(s => s.type === 'GRAND');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{groundSkills.map(renderSkillNode)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-600 pt-6">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Advanced Skills</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{grandSkills.map(renderSkillNode)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||||
|
<div className={`bg-gray-800 p-6 rounded-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto ${styles.customScroll}`}>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Skill Tree</h2>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center h-40">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderSkillTree()
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
19
src/config/firebase.ts
Normal file
19
src/config/firebase.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { initializeApp } from 'firebase/app';
|
||||||
|
import { getFirestore } from 'firebase/firestore';
|
||||||
|
import { getAuth } from 'firebase/auth';
|
||||||
|
import { getAnalytics } from 'firebase/analytics';
|
||||||
|
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: import.meta.env.VITE_FIREBASE_API_KEY || 'VITE_FIREBASE_API_KEY',
|
||||||
|
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || 'VITE_FIREBASE_AUTH_DOMAIN',
|
||||||
|
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID || 'VITE_FIREBASE_PROJECT_ID',
|
||||||
|
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET || 'VITE_FIREBASE_STORAGE_BUCKET',
|
||||||
|
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID || 'VITE_FIREBASE_MESSAGING_SENDER_ID',
|
||||||
|
appId: import.meta.env.VITE_FIREBASE_APP_ID || 'VITE_FIREBASE_APP_ID',
|
||||||
|
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID || 'VITE_FIREBASE_MEASUREMENT_ID',
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
export const db = getFirestore(app);
|
||||||
|
export const auth = getAuth(app);
|
||||||
|
export const analytics = getAnalytics(app);
|
@@ -1,3 +1,18 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop-blur-sm {
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add smooth transitions */
|
||||||
|
.transition-all {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
@@ -1,57 +1,69 @@
|
|||||||
import { ApiService } from './index';
|
import { ApiService } from './index';
|
||||||
import { Pet, Resources } from '../../types/Pet';
|
import { InvItemInteraction, Pet, Resources } from '../../types/Pet';
|
||||||
import { PetCreationRequest } from '../../types/PetCreationRequest';
|
import { PetCreationRequest } from '../../types/PetCreationRequest';
|
||||||
import { PetUpdateActionRequest } from '../../types/PetUpdateActionRequest';
|
import { PetActionGathered, PetUpdateActionRequest } from '../../types/PetAction';
|
||||||
|
import { PetSkill, Skill } from '../../types/Skills';
|
||||||
|
import { GameItem } from '../../types/GameItem';
|
||||||
|
|
||||||
// Get API service instance
|
// Get API service instance
|
||||||
const api = ApiService.getInstance();
|
const api = ApiService.getInstance();
|
||||||
|
|
||||||
export async function fetchPets(): Promise<Pet[]> {
|
export async function fetchPets(): Promise<Pet[]> {
|
||||||
try {
|
|
||||||
const response = await api.get<Pet[]>('/api/v1/pet');
|
const response = await api.get<Pet[]>('/api/v1/pet');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to fetch pets:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPet(data: PetCreationRequest): Promise<Pet> {
|
export async function createPet(data: PetCreationRequest): Promise<Pet> {
|
||||||
try {
|
|
||||||
const response = await api.post<Pet>('/api/v1/pet', data);
|
const response = await api.post<Pet>('/api/v1/pet', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to create pet:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePetAction(petId: string, data: PetUpdateActionRequest): Promise<Pet> {
|
export async function updatePetAction(petId: string, data: PetUpdateActionRequest): Promise<Pet> {
|
||||||
try {
|
const response = await api.put<Pet>(`/api/v1/pet/${petId}/action`, data);
|
||||||
const response = await api.put<Pet>(`/api/v1/pet/${petId}/action/gather`, data);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to update pet action:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPetGatheredResources(petId: string): Promise<Resources> {
|
export async function getPetGatheredResources(petId: string): Promise<PetActionGathered[]> {
|
||||||
try {
|
const response = await api.get<PetActionGathered[]>(`/api/v1/pet/${petId}/resources/gathered`);
|
||||||
const response = await api.get<Resources>(`/api/v1/pet/${petId}/resources/gathered`);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to fetch pet gathered resources:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function putPetCollectResources(petId: string): Promise<Pet> {
|
export async function putPetCollectResources(petId: string): Promise<Pet> {
|
||||||
try {
|
|
||||||
const response = await api.put<Pet>(`/api/v1/pet/${petId}/resources/collect`);
|
const response = await api.put<Pet>(`/api/v1/pet/${petId}/resources/collect`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
}
|
||||||
console.error('Failed to collect pet gathered resources:', error.message);
|
|
||||||
throw error;
|
export async function getAllSkills(): Promise<Skill[]> {
|
||||||
}
|
const response = await api.get<Skill[]>(`/api/v1/skill`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPetSkills(petId: string): Promise<PetSkill[]> {
|
||||||
|
const response = await api.get<PetSkill[]>(`/api/v1/skill/${petId}/skills`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postAllocatePetSkill(petId: string, skillId: number): Promise<PetSkill> {
|
||||||
|
const response = await api.post<PetSkill>(`/api/v1/skill/${petId}/allocate/${skillId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putPetItemInteract(petId: string, itemId: number, inter: InvItemInteraction): Promise<Pet> {
|
||||||
|
const response = await api.put<Pet>(`/api/v1/inventory/${petId}/${itemId}/${inter.toLowerCase()}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItemInfo(itemId: number): Promise<GameItem> {
|
||||||
|
const response = await api.get<GameItem>(`/api/v1/gamedata/item/${itemId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItemIcon(itemId: number): Promise<Blob> {
|
||||||
|
const response = await api.get<Blob>(`/api/v1/gamedata/item/${itemId}/icon`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
headers: {
|
||||||
|
Accept: 'image/png'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
}
|
}
|
@@ -2,7 +2,7 @@ import { ApiConfig } from './types';
|
|||||||
|
|
||||||
// API configuration
|
// API configuration
|
||||||
export const apiConfig: ApiConfig = {
|
export const apiConfig: ApiConfig = {
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5278',
|
baseURL: import.meta.env.VITE_API_BASE_URL || 'VITE_API_BASE_URL',
|
||||||
timeout: 10000, // 10 seconds
|
timeout: 10000, // 10 seconds
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
@@ -49,4 +49,8 @@ export class ApiErrorHandler {
|
|||||||
static isClientError(error: ApiError): boolean {
|
static isClientError(error: ApiError): boolean {
|
||||||
return error.status >= 400 && error.status < 500;
|
return error.status >= 400 && error.status < 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static isUnauthorizedError(error: ApiError): boolean {
|
||||||
|
return error.status === 401;
|
||||||
|
}
|
||||||
}
|
}
|
@@ -1,45 +0,0 @@
|
|||||||
// Example usage of the API service
|
|
||||||
import { ApiService } from './index';
|
|
||||||
import { Pet } from '../../types/Pet';
|
|
||||||
|
|
||||||
// Get API service instance
|
|
||||||
const api = ApiService.getInstance();
|
|
||||||
|
|
||||||
// Example functions using the API service
|
|
||||||
export async function getPet(id: string) {
|
|
||||||
try {
|
|
||||||
const response = await api.get<Pet>(`/pets/${id}`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (api.isNetworkError(error)) {
|
|
||||||
console.error('Network error occurred');
|
|
||||||
} else if (api.isTimeoutError(error)) {
|
|
||||||
console.error('Request timed out');
|
|
||||||
} else if (api.isServerError(error)) {
|
|
||||||
console.error('Server error occurred');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updatePet(id: string, data: Partial<Pet>) {
|
|
||||||
try {
|
|
||||||
const response = await api.put<Pet>(`/pets/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to update pet:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gatherResources(id: string, resourceType: string) {
|
|
||||||
try {
|
|
||||||
const response = await api.post<{ success: boolean }>(`/pets/${id}/gather`, {
|
|
||||||
resourceType,
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to gather resources:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -3,17 +3,60 @@ import { apiConfig } from './config';
|
|||||||
import { setupInterceptors } from './interceptors';
|
import { setupInterceptors } from './interceptors';
|
||||||
import { ApiErrorHandler } from './error';
|
import { ApiErrorHandler } from './error';
|
||||||
import { ApiResponse, RequestOptions, ApiError } from './types';
|
import { ApiResponse, RequestOptions, ApiError } from './types';
|
||||||
|
import { auth } from '../../config/firebase';
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
private static instance: ApiService;
|
private static instance: ApiService;
|
||||||
private axios: AxiosInstance;
|
private axiosInstance: AxiosInstance;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
// Create axios instance with base configuration
|
// Create axios instance with base configuration
|
||||||
this.axios = axios.create(apiConfig);
|
this.axiosInstance = axios.create(apiConfig);
|
||||||
|
|
||||||
// Setup interceptors
|
// Setup interceptors
|
||||||
setupInterceptors(this.axios);
|
setupInterceptors(this.axiosInstance);
|
||||||
|
|
||||||
|
// Add request interceptor
|
||||||
|
this.axiosInstance.interceptors.request.use(
|
||||||
|
async (config) => {
|
||||||
|
const token = await auth.currentUser?.getIdToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add response interceptor
|
||||||
|
this.axiosInstance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
// Check if error is 401 and we haven't tried to refresh token yet
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Force token refresh
|
||||||
|
const user = auth.currentUser;
|
||||||
|
if (user) {
|
||||||
|
const newToken = await user.getIdToken(true);
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
localStorage.setItem('TOKEN', newToken);
|
||||||
|
return this.axiosInstance(originalRequest);
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getInstance(): ApiService {
|
public static getInstance(): ApiService {
|
||||||
@@ -40,7 +83,7 @@ class ApiService {
|
|||||||
config.data = data;
|
config.data = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.axios.request<T>(config);
|
const response = await this.axiosInstance.request<T>(config);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: response.data,
|
data: response.data,
|
||||||
|
@@ -29,4 +29,5 @@ export interface RequestOptions {
|
|||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
params?: Record<string, any>;
|
params?: Record<string, any>;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
responseType? : 'json' | 'blob' | 'text';
|
||||||
}
|
}
|
10
src/services/api/userApi.ts
Normal file
10
src/services/api/userApi.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Pet } from '../../types/Pet';
|
||||||
|
import { ApiService } from './index';
|
||||||
|
|
||||||
|
// Get API service instance
|
||||||
|
const api = ApiService.getInstance();
|
||||||
|
|
||||||
|
export async function fetchPets(): Promise<Pet[]> {
|
||||||
|
const response = await api.get<Pet[]>('/api/v1/pet');
|
||||||
|
return response.data;
|
||||||
|
}
|
40
src/services/auth/auth.ts
Normal file
40
src/services/auth/auth.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
signInWithEmailAndPassword,
|
||||||
|
createUserWithEmailAndPassword,
|
||||||
|
signOut as firebaseSignOut,
|
||||||
|
User,
|
||||||
|
GoogleAuthProvider,
|
||||||
|
signInWithPopup
|
||||||
|
} from 'firebase/auth';
|
||||||
|
import { auth } from '../../config/firebase';
|
||||||
|
|
||||||
|
export async function login(email: string, password: string): Promise<string> {
|
||||||
|
const userCredential = await signInWithEmailAndPassword(auth, email, password);
|
||||||
|
const token = await userCredential.user.getIdToken();
|
||||||
|
localStorage.setItem('TOKEN', token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(email: string, password: string): Promise<string> {
|
||||||
|
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
|
||||||
|
const token = await userCredential.user.getIdToken();
|
||||||
|
localStorage.setItem('TOKEN', token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOut(): Promise<void> {
|
||||||
|
await firebaseSignOut(auth);
|
||||||
|
localStorage.removeItem('TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentUser(): User | null {
|
||||||
|
return auth.currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signInWithGoogle(): Promise<string> {
|
||||||
|
const provider = new GoogleAuthProvider();
|
||||||
|
const userCredential = await signInWithPopup(auth, provider);
|
||||||
|
const token = await userCredential.user.getIdToken();
|
||||||
|
localStorage.setItem('TOKEN', token);
|
||||||
|
return token;
|
||||||
|
}
|
31
src/types/GameItem.ts
Normal file
31
src/types/GameItem.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export interface GameItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: ItemType;
|
||||||
|
rarity: ItemRarity;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
effect: string;
|
||||||
|
equipTarget: ItemEquipTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ItemType {
|
||||||
|
Material = 'Material',
|
||||||
|
Consumable = 'Consumable',
|
||||||
|
Equipment = 'Equipment'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ItemRarity {
|
||||||
|
Common = 'Common', // White
|
||||||
|
Uncommon = 'Uncommon', // Green
|
||||||
|
Rare = 'Rare', // Blue
|
||||||
|
Legendary = 'Legendary' // Purple
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ItemEquipTarget {
|
||||||
|
None = 'None',
|
||||||
|
Head = 'Head',
|
||||||
|
Body = 'Body',
|
||||||
|
Legs = 'Legs',
|
||||||
|
Weapon = 'Weapon'
|
||||||
|
}
|
@@ -1,4 +1,4 @@
|
|||||||
import { PetAction } from "./PetUpdateActionRequest";
|
import { PetBasicAction, PetGatherAction } from "./PetAction";
|
||||||
|
|
||||||
export type PetClass = 'FOREST_SPIRIT' | 'OCEAN_GUARDIAN' | 'FIRE_ELEMENTAL' | 'MYTHICAL_BEAST' | 'SHADOW_WALKER' | 'CYBER_PET' | 'BIO_MECHANICAL';
|
export type PetClass = 'FOREST_SPIRIT' | 'OCEAN_GUARDIAN' | 'FIRE_ELEMENTAL' | 'MYTHICAL_BEAST' | 'SHADOW_WALKER' | 'CYBER_PET' | 'BIO_MECHANICAL';
|
||||||
|
|
||||||
@@ -6,6 +6,9 @@ export interface PetStats {
|
|||||||
intelligence: number;
|
intelligence: number;
|
||||||
strength: number;
|
strength: number;
|
||||||
charisma: number;
|
charisma: number;
|
||||||
|
maxIntelligence: number;
|
||||||
|
maxStrength: number;
|
||||||
|
maxCharisma: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Resources {
|
export interface Resources {
|
||||||
@@ -22,7 +25,19 @@ export interface Pet {
|
|||||||
stats: PetStats;
|
stats: PetStats;
|
||||||
resources: Resources;
|
resources: Resources;
|
||||||
level: number;
|
level: number;
|
||||||
petAction: PetAction;
|
health: number;
|
||||||
|
maxHealth: number;
|
||||||
|
petGatherAction: PetGatherAction;
|
||||||
|
basicActionCooldown: Date;
|
||||||
|
petBasicAction: PetBasicAction;
|
||||||
|
skillPoints: number;
|
||||||
|
inventory: Inventory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvItemInteraction = 'EQUIP' | 'UNEQUIP' | 'USE' | 'DROP';
|
||||||
|
export interface Inventory {
|
||||||
|
items: number[];
|
||||||
|
capacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PetClassInfo {
|
export interface PetClassInfo {
|
||||||
|
22
src/types/PetAction.ts
Normal file
22
src/types/PetAction.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export type PetBasicAction = 'UNKNOWN' | 'FEED' | 'PLAY' | 'SLEEP';
|
||||||
|
export type PetGatherAction = 'IDLE' | 'GATHERING_WISDOM' | 'GATHERING_GOLD' | 'GATHERING_FOOD' | 'EXPLORE' | 'BATTLE';
|
||||||
|
|
||||||
|
export interface PetUpdateActionRequest {
|
||||||
|
basicAction?: PetBasicAction;
|
||||||
|
gatherAction?: PetGatherAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PetActionGathered {
|
||||||
|
petId: string;
|
||||||
|
resource: string;
|
||||||
|
itemId: number;
|
||||||
|
amount: number;
|
||||||
|
gameItem: PetGatheredItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PetGatheredItem {
|
||||||
|
id: number;
|
||||||
|
name: number;
|
||||||
|
type: string;
|
||||||
|
rarity: string;
|
||||||
|
}
|
@@ -1,6 +1,4 @@
|
|||||||
import { PetClass } from "./Pet";
|
|
||||||
|
|
||||||
export interface PetCreationRequest {
|
export interface PetCreationRequest {
|
||||||
name: string;
|
name: string;
|
||||||
class: PetClass;
|
class: string;
|
||||||
}
|
}
|
@@ -1,5 +0,0 @@
|
|||||||
export type PetAction = 'IDLE' | 'GATHERING_WISDOM' | 'GATHERING_GOLD' | 'GATHERING_FOOD';
|
|
||||||
|
|
||||||
export interface PetUpdateActionRequest {
|
|
||||||
petActionGather: PetAction;
|
|
||||||
}
|
|
34
src/types/Skills.ts
Normal file
34
src/types/Skills.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export type SkillType = 'GROUND' | 'GRAND';
|
||||||
|
|
||||||
|
export interface Skill {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: SkillType;
|
||||||
|
skillRequirements: SkillRequirement[];
|
||||||
|
icon: string;
|
||||||
|
skillsIdRequired: number[] | null;
|
||||||
|
effects: SkillEffect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillRequirement {
|
||||||
|
cost: number;
|
||||||
|
resource: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillEffect {
|
||||||
|
id: number;
|
||||||
|
skillId: number;
|
||||||
|
tier: SkillTier;
|
||||||
|
effect: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkillTier = 'I' | 'II' | 'III';
|
||||||
|
export interface PetSkill {
|
||||||
|
id: number;
|
||||||
|
petId: string;
|
||||||
|
skillId: number;
|
||||||
|
skill: Skill;
|
||||||
|
currentTier: SkillTier;
|
||||||
|
}
|
@@ -1,14 +1,29 @@
|
|||||||
import { PetAction } from '../types/PetUpdateActionRequest';
|
import { PetGatherAction } from '../types/PetAction';
|
||||||
|
|
||||||
export function isGatheringAction(action: PetAction): boolean {
|
export function isGatheringAction(action: PetGatherAction): boolean {
|
||||||
return action.startsWith('GATHERING_');
|
return action.startsWith('GATHERING_');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getResourceFromAction(action: PetAction): string | null {
|
export function isActionActive(currentAction: PetGatherAction, actionType: 'gather' | 'explore' | 'battle'): boolean {
|
||||||
if (!isGatheringAction(action)) return null;
|
if (actionType === 'gather') {
|
||||||
|
return currentAction.startsWith('GATHERING_');
|
||||||
|
}
|
||||||
|
if (actionType === 'explore') {
|
||||||
|
return currentAction === 'EXPLORE';
|
||||||
|
}
|
||||||
|
if (actionType === 'battle') {
|
||||||
|
return currentAction === 'BATTLE';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResourceFromAction(action: PetGatherAction): string | null {
|
||||||
|
if (action.startsWith('GATHERING_')) {
|
||||||
return action.replace('GATHERING_', '').toLowerCase();
|
return action.replace('GATHERING_', '').toLowerCase();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatResourceName(resource: string): string {
|
export function formatResourceName(resource: string): string {
|
||||||
return resource.charAt(0).toUpperCase() + resource.slice(1);
|
return resource.charAt(0).toUpperCase() + resource.slice(1).toLowerCase();
|
||||||
}
|
}
|
Reference in New Issue
Block a user