Compare commits

..

13 Commits

Author SHA1 Message Date
0be511c245 feat: update Gitea workflow trigger and enhance API error handling with token refresh logic 2025-02-16 13:16:41 -03:00
2487076e92 feat: add production environment variables and update Gitea workflow for deployment
All checks were successful
Main Build & Deploy / Build and Push Docker Image (amd64 and arm64) (push) Successful in 2m59s
Main Build & Deploy / Deploy Live (push) Successful in 5s
2025-02-16 11:47:28 -03:00
1f2a71452b feat: add environment variable handling in Dockerfile and update Firebase configuration 2025-02-16 11:38:30 -03:00
e5af3b8c51 feat: add environment variables for Firebase configuration in Dockerfile 2025-02-16 11:18:22 -03:00
d1a08d055a refactor: remove security headers from nginx configuration 2025-02-16 11:08:36 -03:00
64ca3bedf4 fix: provide default values for Firebase configuration variables 2025-02-16 11:03:53 -03:00
53b9694b09 refactor: remove Docker installation steps from Gitea workflow 2025-02-15 22:29:45 -03:00
9fbff6d8f2 feat: add Dockerfile and GitHub Actions workflow for build and deployment 2025-02-15 22:19:57 -03:00
a12cfc5a2a feat: integrate Firebase for authentication and data management, add GameItem type, and enhance UI with tooltips 2025-02-15 21:54:28 -03:00
62634a426e feat: implement item icon loading in InventoryModal and add API function to fetch item icons 2025-02-12 21:22:22 -03:00
88a9c6507c refactor: reorganize pet action types and update related components for improved resource management 2025-02-09 21:23:07 -03:00
c2e5bf92a3 feat: enhance PetDisplay and SkillTreeModal with resource management and skill allocation logic 2025-02-08 22:46:35 -03:00
78c0f52c39 feat: enhance SkillTreeModal with skill allocation logic and custom scrollbar styles 2025-02-08 20:52:44 -03:00
34 changed files with 2055 additions and 128 deletions

7
.env.example Normal file
View 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
View 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
View 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
View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env

18
Dockerfile Normal file
View 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
View 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

10
nginx.conf Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"axios": "^1.6.7",
"firebase": "^11.3.1",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -31,4 +32,4 @@
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}
}

View File

@@ -1,29 +1,45 @@
import { useState, useEffect } from 'react';
import { onAuthStateChanged } from 'firebase/auth';
import { auth } from './config/firebase';
import PetDisplay from './components/PetDisplay';
import InteractionMenu from './components/InteractionMenu';
import PetRegister from './components/PetRegister';
import AnimatedBackground from './components/AnimatedBackground';
import AuthForm from './components/auth/AuthForm';
import { Pet } from './types/Pet';
import { fetchPets } from './services/api/api';
export default function App() {
const [pet, setPet] = useState<Pet | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadPets = async () => {
try {
const pets = await fetchPets();
if (pets.length > 0) {
setPet(pets[0]);
}
} catch (error) {
console.error('Error loading pets:', error);
}
};
const unsubscribe = onAuthStateChanged(auth, (user) => {
setIsAuthenticated(!!user);
setIsLoading(false);
});
loadPets();
return () => unsubscribe();
}, []);
useEffect(() => {
if (isAuthenticated) {
const loadPets = async () => {
try {
const pets = await fetchPets();
if (pets.length > 0) {
setPet(pets[0]);
}
} catch (error) {
console.error('Error loading pets:', error);
}
};
loadPets();
}
}, [isAuthenticated]);
const handleCustomize = () => {
console.log('Customize pet');
};
@@ -32,6 +48,25 @@ export default function App() {
setPet(updatedPet);
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-white">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return (
<>
<AnimatedBackground />
<div className="relative z-10">
<AuthForm onAuthSuccess={() => setIsAuthenticated(true)} />
</div>
</>
);
}
if (!pet) {
return (
<>

View File

@@ -1,5 +1,6 @@
import { LucideIcon } from 'lucide-react';
import { Pet, Resources } from '../types/Pet';
import { Pet } from '../types/Pet';
import Tooltip from './Tooltip';
import { isActionActive, formatResourceName, getResourceFromAction } from '../utils/petUtils';
const colorClassMap = {
@@ -11,6 +12,15 @@ const colorClassMap = {
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',
@@ -20,6 +30,15 @@ const getActionVerb = (actionType: 'gather' | 'explore' | 'battle'): string => {
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 {
@@ -28,9 +47,9 @@ interface ActionResourceButtonProps {
label: string;
actionType: 'gather' | 'explore' | 'battle';
color: ButtonColor;
actionText?: string;
onActionClick: () => void;
onActionComplete: (updatedPet: Pet) => void;
onResourcesUpdate: (resources: Resources) => void;
}
export default function ActionResourceButton({
@@ -39,25 +58,43 @@ export default function ActionResourceButton({
label,
actionType,
color,
actionText,
onActionClick
}: ActionResourceButtonProps) {
const isActive = isActionActive(pet.petGatherAction, actionType);
const currentResource = getResourceFromAction(pet.petGatherAction);
return (
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
${colorClassMap[color]}
${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>
{isActive && currentResource
? `${getActionVerb(actionType)} ${formatResourceName(currentResource)}`
: label}
</span>
<span>{getButtonText()}</span>
</button>
);
return (
<Tooltip content={actionText || ''}>
{button}
</Tooltip>
);
}

View File

@@ -1,20 +1,23 @@
import { Resources } from '../types/Pet';
import { putPetCollectResources } from '../services/api/api';
import { PetActionGathered } from '../types/PetAction';
import { Pet } from '../types/Pet';
interface CollectResourcesButtonProps {
petId: string;
resources: Resources;
resources: PetActionGathered[];
onCollect: () => void;
onPetUpdate: (pet: Pet) => void;
}
export default function CollectResourcesButton({ petId, resources, onCollect }: CollectResourcesButtonProps) {
const hasResources = Object.values(resources).some(value => value > 0);
export default function CollectResourcesButton({ petId, resources, onCollect, onPetUpdate }: CollectResourcesButtonProps) {
const hasResources = Object.values(resources).length > 0;
if (!hasResources) return null;
const handleCollect = async () => {
try {
await putPetCollectResources(petId);
const updatedPet = await putPetCollectResources(petId);
onPetUpdate(updatedPet);
onCollect();
} catch (error) {
console.error('Failed to collect resources:', error);
@@ -24,17 +27,20 @@ export default function CollectResourcesButton({ petId, resources, onCollect }:
return (
<button
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
border-2 border-green-500/50 rounded-lg p-4
transition-all duration-300 transform hover:scale-105 w-full mt-2"
>
<span>
Collect: {Object.entries(resources)
.filter(([_, value]) => value > 0)
.map(([key, value]) => `${value} ${key}`)
.join(', ')}
</span>
<span className="font-bold mb-1">Collect:</span>
{resources.map((item, index) => (
<span key={index}>
{item.gameItem
? item.gameItem.name
: `${item.resource} x${item.amount}`
}
</span>
))}
</button>
);
}

View File

@@ -1,13 +1,12 @@
import { Pizza, PlayCircle, Moon, Compass, Sword, FeatherIcon } from 'lucide-react';
import CollectResourcesButton from './CollectResourcesButton';
import { Pet, Resources } from '../types/Pet';
import { Pet } from '../types/Pet';
import { useState, useEffect } from 'react';
import { updatePetAction, getPetGatheredResources } from '../services/api/api';
import { PetBasicAction } from '../types/PetUpdateActionRequest';
import { PetActionGathered, PetBasicAction, PetGatherAction } from '../types/PetAction';
import ActionButton from './button/ActionButton';
import ActionResourceButton from './ActionResourceButton';
import ResourceSelectionModal from './modal/ResourceSelectionModal';
import { PetAction } from '../types/PetUpdateActionRequest';
interface InteractionMenuProps {
pet: Pet;
@@ -16,10 +15,9 @@ interface InteractionMenuProps {
}
export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuProps) {
const [gatheredResources, setGatheredResources] = useState<Resources>({ wisdom: 0, gold: 0, food: 0, junk: 0 });
const [gatheredResources, setGatheredResources] = useState<PetActionGathered[]>([]);
const [remainingCooldown, setRemainingCooldown] = useState<number | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedActionType, setSelectedActionType] = useState<'gather' | 'explore' | 'battle' | null>(null);
useEffect(() => {
const updateCooldown = () => {
@@ -78,14 +76,6 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
onPetUpdate(updatedPet);
};
const handleResourcesUpdate = (resources: Resources) => {
setGatheredResources(resources);
};
const handleCollect = () => {
setGatheredResources({ wisdom: 0, gold: 0, food: 0, junk: 0 });
};
function performBasicAction(basicAction: PetBasicAction): () => void {
return async () => {
try {
@@ -99,13 +89,12 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
const handleActionStart = async (actionType: 'gather' | 'explore' | 'battle') => {
if (actionType === 'gather') {
setSelectedActionType(actionType);
setIsModalOpen(true);
return;
}
try {
const action: PetAction = actionType === 'explore' ? 'EXPLORING' : 'BATTLE';
const action: PetGatherAction = actionType === 'explore' ? 'EXPLORE' : 'BATTLE';
const updatedPet = await updatePetAction(pet.id, { gatherAction: action });
onPetUpdate(updatedPet);
} catch (error) {
@@ -126,7 +115,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
}
try {
const action: PetAction = `GATHERING_${resourceType.toUpperCase()}` as PetAction;
const action: PetGatherAction = `GATHERING_${resourceType.toUpperCase()}` as PetGatherAction;
const updatedPet = await updatePetAction(pet.id, { gatherAction: action });
onPetUpdate(updatedPet);
} catch (error) {
@@ -136,6 +125,10 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
}
};
const handleCollect = () => {
setGatheredResources([]);
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 bg-gray-900 p-6 rounded-xl shadow-xl">
{remainingCooldown !== null && (
@@ -150,6 +143,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
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}
@@ -157,6 +151,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
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}
@@ -164,6 +159,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
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">
@@ -175,7 +171,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
color="amber"
onActionClick={() => handleActionStart('gather')}
onActionComplete={handleGatherComplete}
onResourcesUpdate={handleResourcesUpdate}
actionText='Send your pet to gather resources<br/>Your pet will collect items over time'
/>
<ActionResourceButton
pet={pet}
@@ -185,7 +181,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
color="emerald"
onActionClick={() => handleActionStart('explore')}
onActionComplete={handleGatherComplete}
onResourcesUpdate={handleResourcesUpdate}
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}
@@ -195,7 +191,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
color="red"
onActionClick={() => handleActionStart('battle')}
onActionComplete={handleGatherComplete}
onResourcesUpdate={handleResourcesUpdate}
actionText='Send your pet to battle based on their strength<br/>Your pet will gain experience and items, but also may lose health'
/>
</div>
@@ -204,6 +200,7 @@ export default function InteractionMenu({ pet, onPetUpdate }: InteractionMenuPro
petId={pet.id}
resources={gatheredResources}
onCollect={handleCollect}
onPetUpdate={onPetUpdate}
/>
</div>

View File

@@ -1,5 +1,5 @@
import { Pet } from '../types/Pet';
import { Brain, Dumbbell, Heart, Sparkles, Coins, Pizza, Trash2, Trophy } from 'lucide-react';
import { Brain, Dumbbell, Smile, Sparkles, Coins, Pizza, Trash2, Trophy, Heart, Star } from 'lucide-react';
import { PET_CLASSES } from '../data/petClasses';
import { useState } from 'react';
import InventoryModal from './modal/InventoryModal';
@@ -11,12 +11,13 @@ interface PetDisplayProps {
}
export default function PetDisplay({ pet, onPetUpdate }: PetDisplayProps) {
const StatBar = ({ value, maxValue, label, icon: Icon }: { value: number; maxValue: number; label: string; icon: any }) => (
<div className="flex items-center space-x-2">
const StatBar = ({ value, maxValue, label, icon: Icon }:
{ value: number; maxValue: number; label: string; icon: any; }) => (
<div className="flex items-center space-x-2" title={label}>
<Icon className="w-5 h-5" />
<div className="flex-1 bg-gray-700 rounded-full h-4">
<div
className="bg-blue-500 rounded-full h-4"
className={`bg-blue-500 rounded-full h-4`}
style={{ width: `${(value / maxValue) * 100}%` }}
/>
</div>
@@ -54,6 +55,14 @@ export default function PetDisplay({ pet, onPetUpdate }: PetDisplayProps) {
</div>
<div className="space-y-4 mb-6">
<div className="space-y-4 mb-6">
<StatBar
value={pet.health}
maxValue={pet.maxHealth}
label="Health"
icon={Heart}
/>
</div>
<StatBar
value={pet.stats.intelligence}
maxValue={pet.stats.maxIntelligence}
@@ -70,7 +79,7 @@ export default function PetDisplay({ pet, onPetUpdate }: PetDisplayProps) {
value={pet.stats.charisma}
maxValue={pet.stats.maxCharisma}
label="Charisma"
icon={Heart}
icon={Smile}
/>
</div>
@@ -107,7 +116,11 @@ export default function PetDisplay({ pet, onPetUpdate }: PetDisplayProps) {
)}
{showSkillTreeModal && (
<SkillTreeModal
onClose={() => setShowSkillTreeModal(false)}
petId={pet.id}
petResources={pet.resources}
onPetUpdate={onPetUpdate}
onClose={() => setShowSkillTreeModal(false)
}
/>
)}
</div>

View 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>
);
}

View 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>
);
}

View File

@@ -1,4 +1,5 @@
import { LucideIcon } from 'lucide-react';
import Tooltip from '../Tooltip';
interface ActionButtonProps {
icon: LucideIcon;
@@ -6,10 +7,11 @@ interface ActionButtonProps {
onClick: () => void;
color: string;
disabled?: boolean;
actionText?: string;
}
export default function ActionButton({ icon: Icon, label, onClick, color, disabled }: ActionButtonProps) {
return (
export default function ActionButton({ icon: Icon, label, onClick, color, disabled, actionText }: ActionButtonProps) {
const button = (
<button
onClick={onClick}
disabled={disabled}
@@ -22,4 +24,14 @@ export default function ActionButton({ icon: Icon, label, onClick, color, disabl
<span>{label}</span>
</button>
);
if (!actionText) {
return button;
}
return (
<Tooltip content={actionText}>
{button}
</Tooltip>
);
}

View File

@@ -1,26 +1,91 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Inventory, InvItemInteraction, Pet } from '../../types/Pet';
import { putPetItemInteract } from '../../services/api/api';
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; // Add this prop
onPetUpdate: (updatedPet: Pet) => void;
}
export default function InventoryModal({ inventory, petId, onClose, onPetUpdate }: InventoryModalProps) {
const capacity = inventory.capacity; // Expected to be 20 for 4 rows x 5 cols
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 handleItemClick = (index: number) => {
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);
setSelectedItemIndex(null);
setSelectedItemDetails(null);
return;
}
else {
setSelectedItemIndex(index);
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);
}
}
};
@@ -35,7 +100,7 @@ export default function InventoryModal({ inventory, petId, onClose, onPetUpdate
};
return (
<div className="fixed inset-0 bg-black/80 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">
<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>
@@ -47,18 +112,59 @@ export default function InventoryModal({ inventory, petId, onClose, onPetUpdate
</button>
</div>
<div className="grid grid-cols-5 grid-rows-4 gap-4">
{gridSlots.map((item, index) => (
{gridSlots.map((itemId, index) => (
<div
key={index}
onClick={() => handleItemClick(index)}
className={`border rounded p-4 flex items-center justify-center h-16
${item !== undefined ? 'bg-gray-700 cursor-pointer' : 'bg-gray-800 text-gray-500'}
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'}`}
>
{item !== undefined ? <span>{item}</span> : <span className="text-gray-500">Empty</span>}
{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}

View 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;
}

View File

@@ -1,17 +1,204 @@
import React from 'react';
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 }: SkillTreeModalProps) {
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/80 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="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>
<p className="text-gray-400 mb-6">Skill tree modal under development.</p>
{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}

19
src/config/firebase.ts Normal file
View 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);

View File

@@ -1,8 +1,9 @@
import { ApiService } from './index';
import { InvItemInteraction, Pet, Resources } from '../../types/Pet';
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
const api = ApiService.getInstance();
@@ -22,8 +23,8 @@ export async function updatePetAction(petId: string, data: PetUpdateActionReques
return response.data;
}
export async function getPetGatheredResources(petId: string): Promise<Resources> {
const response = await api.get<Resources>(`/api/v1/pet/${petId}/resources/gathered`);
export async function getPetGatheredResources(petId: string): Promise<PetActionGathered[]> {
const response = await api.get<PetActionGathered[]>(`/api/v1/pet/${petId}/resources/gathered`);
return response.data;
}
@@ -38,7 +39,7 @@ export async function getAllSkills(): Promise<Skill[]> {
}
export async function getPetSkills(petId: string): Promise<PetSkill[]> {
const response = await api.get<PetSkill[]>(`/api/v1/skill/${petId}/pet`);
const response = await api.get<PetSkill[]>(`/api/v1/skill/${petId}/skills`);
return response.data;
}
@@ -50,4 +51,19 @@ export async function postAllocatePetSkill(petId: string, skillId: number): Prom
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;
}

View File

@@ -2,7 +2,7 @@ import { ApiConfig } from './types';
// API configuration
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
headers: {
'Content-Type': 'application/json',

View File

@@ -49,4 +49,8 @@ export class ApiErrorHandler {
static isClientError(error: ApiError): boolean {
return error.status >= 400 && error.status < 500;
}
public static isUnauthorizedError(error: ApiError): boolean {
return error.status === 401;
}
}

View File

@@ -3,17 +3,60 @@ import { apiConfig } from './config';
import { setupInterceptors } from './interceptors';
import { ApiErrorHandler } from './error';
import { ApiResponse, RequestOptions, ApiError } from './types';
import { auth } from '../../config/firebase';
class ApiService {
private static instance: ApiService;
private axios: AxiosInstance;
private axiosInstance: AxiosInstance;
private constructor() {
// Create axios instance with base configuration
this.axios = axios.create(apiConfig);
this.axiosInstance = axios.create(apiConfig);
// 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 {
@@ -40,7 +83,7 @@ class ApiService {
config.data = data;
}
const response = await this.axios.request<T>(config);
const response = await this.axiosInstance.request<T>(config);
return {
data: response.data,

View File

@@ -29,4 +29,5 @@ export interface RequestOptions {
headers?: Record<string, string>;
params?: Record<string, any>;
timeout?: number;
responseType? : 'json' | 'blob' | 'text';
}

View 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
View 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
View 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'
}

View File

@@ -1,4 +1,4 @@
import { PetBasicAction, PetGatherAction } from "./PetUpdateActionRequest";
import { PetBasicAction, PetGatherAction } from "./PetAction";
export type PetClass = 'FOREST_SPIRIT' | 'OCEAN_GUARDIAN' | 'FIRE_ELEMENTAL' | 'MYTHICAL_BEAST' | 'SHADOW_WALKER' | 'CYBER_PET' | 'BIO_MECHANICAL';
@@ -25,7 +25,6 @@ export interface Pet {
stats: PetStats;
resources: Resources;
level: number;
experience: number;
health: number;
maxHealth: number;
petGatherAction: PetGatherAction;

22
src/types/PetAction.ts Normal file
View 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;
}

View File

@@ -1,7 +0,0 @@
export type PetBasicAction = 'UNKNOWN' | 'FEED' | 'PLAY' | 'SLEEP';
export type PetGatherAction = 'IDLE' | 'GATHERING_WISDOM' | 'GATHERING_GOLD' | 'GATHERING_FOOD';
export interface PetUpdateActionRequest {
basicAction?: PetBasicAction;
gatherAction?: PetGatherAction;
}

View File

@@ -1,26 +1,34 @@
export type SkillType = 'GROUND' | 'GRAND';
export interface Skill {
id: number;
name: string;
description: string;
type: string;
pointsCost: number;
type: SkillType;
skillRequirements: SkillRequirement[];
icon: string;
skillsIdRequired: number | null;
skillsIdRequired: number[] | null;
effects: SkillEffect[];
}
export interface SkillRequirement {
cost: number;
resource: string;
}
export interface SkillEffect {
id: number;
skillId: number;
tier: string;
tier: SkillTier;
effect: string;
value: number;
}
export type SkillTier = 'I' | 'II' | 'III';
export interface PetSkill {
id: number;
petId: string;
skillId: number;
skill: Skill;
currentTier: string;
currentTier: SkillTier;
}

View File

@@ -1,34 +1,29 @@
import { PetGatherAction } from '../types/PetUpdateActionRequest';
import { PetGatherAction } from '../types/PetAction';
export function isGatheringAction(action: PetGatherAction): boolean {
return action.startsWith('GATHERING_');
}
export function getResourceFromAction(action: string): string | null {
if (!action) return null;
const patterns = ['GATHERING_', 'EXPLORING_', 'BATTLE_'];
for (const pattern of patterns) {
if (action.startsWith(pattern)) {
return action.replace(pattern, '').toLowerCase();
}
export function isActionActive(currentAction: PetGatherAction, actionType: 'gather' | 'explore' | 'battle'): boolean {
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 null;
}
export function formatResourceName(resource: string): string {
return resource.charAt(0).toUpperCase() + resource.slice(1);
}
export function isActionActive(action: string, actionType: string): boolean {
if (!action) return false;
const actionMap = {
'gather': 'GATHERING_',
'explore': 'EXPLORING_',
'battle': 'BATTLE_'
};
return action.startsWith(actionMap[actionType] || '');
return resource.charAt(0).toUpperCase() + resource.slice(1).toLowerCase();
}