Initial commit
This commit is contained in:
0
.accesslog
Executable file
0
.accesslog
Executable file
128
.config
Executable file
128
.config
Executable file
@@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
"Meta": {
|
||||||
|
"Strict": true,
|
||||||
|
"Retries": 10,
|
||||||
|
"MaxDeletes": 10,
|
||||||
|
"SkipDirNlink": 20,
|
||||||
|
"CaseInsensi": false,
|
||||||
|
"ReadOnly": false,
|
||||||
|
"NoBGJob": true,
|
||||||
|
"OpenCache": 0,
|
||||||
|
"OpenCacheLimit": 10000,
|
||||||
|
"Heartbeat": 12000000000,
|
||||||
|
"MountPoint": "/tmp/storage/containers/rundjuicefs-31fe40a7-808b-4861-a3c6-5e1361ba66cd-my-project",
|
||||||
|
"Subdir": "/0954660f-fdaf-430e-9c08-43d856f4b183/chat-97147419-5634-40fa-8c67-d722ea396734/my-project",
|
||||||
|
"AtimeMode": "noatime",
|
||||||
|
"DirStatFlushPeriod": 1000000000,
|
||||||
|
"SkipDirMtime": 100000000,
|
||||||
|
"Sid": 4039678,
|
||||||
|
"SortDir": false,
|
||||||
|
"FastStatfs": false,
|
||||||
|
"TTLCleanupInterval": 1800000000000
|
||||||
|
},
|
||||||
|
"Format": {
|
||||||
|
"Name": "pcs-ue6ju0nuiu0hz7tjc-0e3odv6t4dackr8s3",
|
||||||
|
"UUID": "ad4b5b55-9406-4e74-b5e1-5422c94dd1fa",
|
||||||
|
"Storage": "oss",
|
||||||
|
"Bucket": "https://pcs-ue6ju0nuiu0hz7tjc-0e3odv6t4dackr8s3.oss-cn-hongkong-internal.aliyuncs.com",
|
||||||
|
"AccessKey": "STS.NXg1AmEjJ1XZCYZMa5mH1q66p",
|
||||||
|
"SecretKey": "removed",
|
||||||
|
"SessionToken": "removed",
|
||||||
|
"BlockSize": 4096,
|
||||||
|
"Compression": "none",
|
||||||
|
"HashPrefix": true,
|
||||||
|
"EncryptAlgo": "aes256gcm-rsa",
|
||||||
|
"TrashDays": 0,
|
||||||
|
"MetaVersion": 1,
|
||||||
|
"MinClientVersion": "1.1.0-A",
|
||||||
|
"DirStats": true,
|
||||||
|
"EnableACL": false,
|
||||||
|
"Consul": "21.0.14.104:8500",
|
||||||
|
"CustomLabels": "cluster:pfs-j6cm9t56111f4x38;uid:1936221977589032",
|
||||||
|
"PushGateway": "http://cn-hongkong-intranet.arms.aliyuncs.com/prometheus/322760eec05a83d258d354fca51498ab/1047553595254976/tiwz7q7d94/cn-hongkong/api/v2"
|
||||||
|
},
|
||||||
|
"Chunk": {
|
||||||
|
"CacheDir": "/var/jfsCache/ad4b5b55-9406-4e74-b5e1-5422c94dd1fa",
|
||||||
|
"CacheMode": 384,
|
||||||
|
"CacheSize": 107374182400,
|
||||||
|
"CacheItems": 0,
|
||||||
|
"CacheChecksum": "extend",
|
||||||
|
"CacheEviction": "2-random",
|
||||||
|
"CacheScanInterval": 3600000000000,
|
||||||
|
"CacheExpire": 0,
|
||||||
|
"OSCache": true,
|
||||||
|
"FreeSpace": 0.1,
|
||||||
|
"AutoCreate": true,
|
||||||
|
"Compress": "none",
|
||||||
|
"MaxUpload": 20,
|
||||||
|
"MaxStageWrite": 1000,
|
||||||
|
"MaxRetries": 10,
|
||||||
|
"UploadLimit": 0,
|
||||||
|
"DownloadLimit": 0,
|
||||||
|
"Writeback": false,
|
||||||
|
"UploadDelay": 0,
|
||||||
|
"UploadHours": "",
|
||||||
|
"HashPrefix": true,
|
||||||
|
"BlockSize": 4194304,
|
||||||
|
"GetTimeout": 60000000000,
|
||||||
|
"PutTimeout": 60000000000,
|
||||||
|
"CacheFullBlock": true,
|
||||||
|
"CacheLargeWrite": false,
|
||||||
|
"BufferSize": 314572800,
|
||||||
|
"Readahead": 33554432,
|
||||||
|
"Prefetch": 1
|
||||||
|
},
|
||||||
|
"Security": {
|
||||||
|
"EnableCap": false,
|
||||||
|
"EnableSELinux": false
|
||||||
|
},
|
||||||
|
"Port": {},
|
||||||
|
"Version": "1.3.0+2025-11-13.7d12dfcb",
|
||||||
|
"AttrTimeout": 1000000000,
|
||||||
|
"DirEntryTimeout": 1000000000,
|
||||||
|
"NegEntryTimeout": 0,
|
||||||
|
"EntryTimeout": 1000000000,
|
||||||
|
"ReaddirCache": false,
|
||||||
|
"BackupMeta": 3600000000000,
|
||||||
|
"BackupSkipTrash": false,
|
||||||
|
"PrefixInternal": false,
|
||||||
|
"HideInternal": false,
|
||||||
|
"AllSquash": {
|
||||||
|
"Uid": 1001,
|
||||||
|
"Gid": 1001
|
||||||
|
},
|
||||||
|
"NonDefaultPermission": true,
|
||||||
|
"UMask": 0,
|
||||||
|
"Pid": 221,
|
||||||
|
"PPid": 212,
|
||||||
|
"CommPath": "/tmp/fuse_fd_comm.212",
|
||||||
|
"StatePath": "/tmp/state212.json",
|
||||||
|
"FuseOpts": {
|
||||||
|
"AllowOther": true,
|
||||||
|
"Options": [
|
||||||
|
"nonempty",
|
||||||
|
"default_permissions"
|
||||||
|
],
|
||||||
|
"MaxBackground": 200,
|
||||||
|
"MaxWrite": 0,
|
||||||
|
"MaxReadAhead": 1048576,
|
||||||
|
"IgnoreSecurityLabels": false,
|
||||||
|
"RememberInodes": false,
|
||||||
|
"FsName": "JuiceFS:pcs-ue6ju0nuiu0hz7tjc-0e3odv6t4dackr8s3",
|
||||||
|
"Name": "juicefs",
|
||||||
|
"SingleThreaded": false,
|
||||||
|
"DisableXAttrs": true,
|
||||||
|
"Debug": false,
|
||||||
|
"EnableLocks": true,
|
||||||
|
"EnableSymlinkCaching": true,
|
||||||
|
"ExplicitDataCacheControl": false,
|
||||||
|
"DirectMount": true,
|
||||||
|
"DirectMountFlags": 0,
|
||||||
|
"EnableAcl": false,
|
||||||
|
"EnableWriteback": false,
|
||||||
|
"DontUmask": true,
|
||||||
|
"OtherCaps": 0,
|
||||||
|
"NoAllocForRead": false,
|
||||||
|
"Timeout": 900000000000
|
||||||
|
}
|
||||||
|
}
|
||||||
59
.dockerignore
Executable file
59
.dockerignore
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Infrastructure files (JuiceFS config, etc.)
|
||||||
|
.config
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
*.test.ts
|
||||||
|
*.spec.ts
|
||||||
|
__tests__
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.eslintcache
|
||||||
|
.tsbuildinfo
|
||||||
68
.gitea/workflows/docker-build.yaml
Executable file
68
.gitea/workflows/docker-build.yaml
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
name: Build and Publish Mana Loop Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: "Custom image tag (optional)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
IMAGE_HOST: gitea.tailf367e3.ts.net
|
||||||
|
IMAGE_OWNER: anexim
|
||||||
|
IMAGE_NAME: mana-loop
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker BuildX
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.IMAGE_HOST }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract metadata for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_HOST }}/${{ env.IMAGE_OWNER }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,prefix=
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_HOST }}/${{ env.IMAGE_OWNER }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
${{ github.event.inputs.image_tag != '' && format('{0}/{1}/{2}:{3}', env.IMAGE_HOST, env.IMAGE_OWNER, env.IMAGE_NAME, github.event.inputs.image_tag) || '' }}
|
||||||
|
${{ env.IMAGE_HOST }}/${{ env.IMAGE_OWNER }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
file: Dockerfile
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Image digest
|
||||||
|
run: |
|
||||||
|
echo "Successfully pushed image tags:"
|
||||||
|
echo " - ${{ env.IMAGE_HOST }}/${{ env.IMAGE_OWNER }}/${{ env.IMAGE_NAME }}:latest"
|
||||||
|
echo " - ${{ env.IMAGE_HOST }}/${{ env.IMAGE_OWNER }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
|
||||||
|
if [ -n "${{ github.event.inputs.image_tag }}" ]; then
|
||||||
|
echo " - ${{ env.IMAGE_HOST }}/${{ env.IMAGE_OWNER }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.image_tag }}"
|
||||||
|
fi
|
||||||
51
.gitignore
vendored
Executable file
51
.gitignore
vendored
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
local-*
|
||||||
|
.claude
|
||||||
|
.z-ai-config
|
||||||
|
dev.log
|
||||||
|
test
|
||||||
|
prompt
|
||||||
|
|
||||||
|
server.log
|
||||||
|
# Skills directory
|
||||||
|
/skills/
|
||||||
117
.zscripts/build.sh
Executable file
117
.zscripts/build.sh
Executable file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 将 stderr 重定向到 stdout,避免 execute_command 因为 stderr 输出而报错
|
||||||
|
exec 2>&1
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 获取脚本所在目录(.zscripts 目录,即 workspace-agent/.zscripts)
|
||||||
|
# 使用 $0 获取脚本路径(兼容 sh 和 bash)
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# Next.js 项目路径
|
||||||
|
NEXTJS_PROJECT_DIR="/home/z/my-project"
|
||||||
|
|
||||||
|
# 检查 Next.js 项目目录是否存在
|
||||||
|
if [ ! -d "$NEXTJS_PROJECT_DIR" ]; then
|
||||||
|
echo "❌ 错误: Next.js 项目目录不存在: $NEXTJS_PROJECT_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 开始构建 Next.js 应用和 mini-services..."
|
||||||
|
echo "📁 Next.js 项目路径: $NEXTJS_PROJECT_DIR"
|
||||||
|
|
||||||
|
# 切换到 Next.js 项目目录
|
||||||
|
cd "$NEXTJS_PROJECT_DIR" || exit 1
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
export NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
BUILD_DIR="/tmp/build_fullstack_$BUILD_ID"
|
||||||
|
echo "📁 清理并创建构建目录: $BUILD_DIR"
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
echo "📦 安装依赖..."
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# 构建 Next.js 应用
|
||||||
|
echo "🔨 构建 Next.js 应用..."
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# 构建 mini-services
|
||||||
|
# 检查 Next.js 项目目录下是否有 mini-services 目录
|
||||||
|
if [ -d "$NEXTJS_PROJECT_DIR/mini-services" ]; then
|
||||||
|
echo "🔨 构建 mini-services..."
|
||||||
|
# 使用 workspace-agent 目录下的 mini-services 脚本
|
||||||
|
sh "$SCRIPT_DIR/mini-services-install.sh"
|
||||||
|
sh "$SCRIPT_DIR/mini-services-build.sh"
|
||||||
|
|
||||||
|
# 复制 mini-services-start.sh 到 mini-services-dist 目录
|
||||||
|
echo " - 复制 mini-services-start.sh 到 $BUILD_DIR"
|
||||||
|
cp "$SCRIPT_DIR/mini-services-start.sh" "$BUILD_DIR/mini-services-start.sh"
|
||||||
|
chmod +x "$BUILD_DIR/mini-services-start.sh"
|
||||||
|
else
|
||||||
|
echo "ℹ️ mini-services 目录不存在,跳过"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 将所有构建产物复制到临时构建目录
|
||||||
|
echo "📦 收集构建产物到 $BUILD_DIR..."
|
||||||
|
|
||||||
|
# 复制 Next.js standalone 构建输出
|
||||||
|
if [ -d ".next/standalone" ]; then
|
||||||
|
echo " - 复制 .next/standalone"
|
||||||
|
cp -r .next/standalone "$BUILD_DIR/next-service-dist/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 复制 Next.js 静态文件
|
||||||
|
if [ -d ".next/static" ]; then
|
||||||
|
echo " - 复制 .next/static"
|
||||||
|
mkdir -p "$BUILD_DIR/next-service-dist/.next"
|
||||||
|
cp -r .next/static "$BUILD_DIR/next-service-dist/.next/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 复制 public 目录
|
||||||
|
if [ -d "public" ]; then
|
||||||
|
echo " - 复制 public"
|
||||||
|
cp -r public "$BUILD_DIR/next-service-dist/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 最后再迁移数据库到 BUILD_DIR/db
|
||||||
|
if [ "$(ls -A ./db 2>/dev/null)" ]; then
|
||||||
|
echo "🗄️ 检测到数据库文件,运行数据库迁移..."
|
||||||
|
DATABASE_URL=file:$BUILD_DIR/db/custom.db bun run db:push
|
||||||
|
echo "✅ 数据库迁移完成"
|
||||||
|
ls -lah $BUILD_DIR/db
|
||||||
|
else
|
||||||
|
echo "ℹ️ db 目录为空,跳过数据库迁移"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 复制 Caddyfile(如果存在)
|
||||||
|
if [ -f "Caddyfile" ]; then
|
||||||
|
echo " - 复制 Caddyfile"
|
||||||
|
cp Caddyfile "$BUILD_DIR/"
|
||||||
|
else
|
||||||
|
echo "ℹ️ Caddyfile 不存在,跳过"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 复制 start.sh 脚本
|
||||||
|
echo " - 复制 start.sh 到 $BUILD_DIR"
|
||||||
|
cp "$SCRIPT_DIR/start.sh" "$BUILD_DIR/start.sh"
|
||||||
|
chmod +x "$BUILD_DIR/start.sh"
|
||||||
|
|
||||||
|
# 打包到 $BUILD_DIR.tar.gz
|
||||||
|
PACKAGE_FILE="${BUILD_DIR}.tar.gz"
|
||||||
|
echo ""
|
||||||
|
echo "📦 打包构建产物到 $PACKAGE_FILE..."
|
||||||
|
cd "$BUILD_DIR" || exit 1
|
||||||
|
tar -czf "$PACKAGE_FILE" .
|
||||||
|
cd - > /dev/null || exit 1
|
||||||
|
|
||||||
|
# # 清理临时目录
|
||||||
|
# rm -rf "$BUILD_DIR"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ 构建完成!所有产物已打包到 $PACKAGE_FILE"
|
||||||
|
echo "📊 打包文件大小:"
|
||||||
|
ls -lh "$PACKAGE_FILE"
|
||||||
1269
.zscripts/dev.out.log
Executable file
1269
.zscripts/dev.out.log
Executable file
File diff suppressed because it is too large
Load Diff
1
.zscripts/dev.pid
Executable file
1
.zscripts/dev.pid
Executable file
@@ -0,0 +1 @@
|
|||||||
|
563
|
||||||
154
.zscripts/dev.sh
Executable file
154
.zscripts/dev.sh
Executable file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 获取脚本所在目录(.zscripts)
|
||||||
|
# 使用 $0 获取脚本路径(与 build.sh 保持一致)
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
log_step_start() {
|
||||||
|
local step_name="$1"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting: $step_name"
|
||||||
|
echo "=========================================="
|
||||||
|
export STEP_START_TIME
|
||||||
|
STEP_START_TIME=$(date +%s)
|
||||||
|
}
|
||||||
|
|
||||||
|
log_step_end() {
|
||||||
|
local step_name="${1:-Unknown step}"
|
||||||
|
local end_time
|
||||||
|
end_time=$(date +%s)
|
||||||
|
local duration=$((end_time - STEP_START_TIME))
|
||||||
|
echo "=========================================="
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Completed: $step_name"
|
||||||
|
echo "[LOG] Step: $step_name | Duration: ${duration}s"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
start_mini_services() {
|
||||||
|
local mini_services_dir="$PROJECT_DIR/mini-services"
|
||||||
|
local started_count=0
|
||||||
|
|
||||||
|
log_step_start "Starting mini-services"
|
||||||
|
if [ ! -d "$mini_services_dir" ]; then
|
||||||
|
echo "Mini-services directory not found, skipping..."
|
||||||
|
log_step_end "Starting mini-services"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found mini-services directory, scanning for sub-services..."
|
||||||
|
|
||||||
|
for service_dir in "$mini_services_dir"/*; do
|
||||||
|
if [ ! -d "$service_dir" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
local service_name
|
||||||
|
service_name=$(basename "$service_dir")
|
||||||
|
echo "Checking service: $service_name"
|
||||||
|
|
||||||
|
if [ ! -f "$service_dir/package.json" ]; then
|
||||||
|
echo "[$service_name] No package.json found, skipping..."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q '"dev"' "$service_dir/package.json"; then
|
||||||
|
echo "[$service_name] No dev script found, skipping..."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting $service_name in background..."
|
||||||
|
(
|
||||||
|
cd "$service_dir"
|
||||||
|
echo "[$service_name] Installing dependencies..."
|
||||||
|
bun install
|
||||||
|
echo "[$service_name] Running bun run dev..."
|
||||||
|
exec bun run dev
|
||||||
|
) >"$PROJECT_DIR/.zscripts/mini-service-${service_name}.log" 2>&1 &
|
||||||
|
|
||||||
|
local service_pid=$!
|
||||||
|
echo "[$service_name] Started in background (PID: $service_pid)"
|
||||||
|
echo "[$service_name] Log: $PROJECT_DIR/.zscripts/mini-service-${service_name}.log"
|
||||||
|
disown "$service_pid" 2>/dev/null || true
|
||||||
|
started_count=$((started_count + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Mini-services startup completed. Started $started_count service(s)."
|
||||||
|
log_step_end "Starting mini-services"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_service() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
local service_name="$3"
|
||||||
|
local max_attempts="${4:-60}"
|
||||||
|
local attempt=1
|
||||||
|
|
||||||
|
echo "Waiting for $service_name to be ready on $host:$port..."
|
||||||
|
|
||||||
|
while [ "$attempt" -le "$max_attempts" ]; do
|
||||||
|
if curl -s --connect-timeout 2 --max-time 5 "http://$host:$port" >/dev/null 2>&1; then
|
||||||
|
echo "$service_name is ready!"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Attempt $attempt/$max_attempts: $service_name not ready yet, waiting..."
|
||||||
|
sleep 1
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "ERROR: $service_name failed to start within $max_attempts seconds"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "${DEV_PID:-}" ] && kill -0 "$DEV_PID" >/dev/null 2>&1; then
|
||||||
|
echo "Stopping Next.js dev server (PID: $DEV_PID)..."
|
||||||
|
kill "$DEV_PID" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
if ! command -v bun >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: bun is not installed or not in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_step_start "bun install"
|
||||||
|
echo "[BUN] Installing dependencies..."
|
||||||
|
bun install
|
||||||
|
log_step_end "bun install"
|
||||||
|
|
||||||
|
log_step_start "bun run db:push"
|
||||||
|
echo "[BUN] Setting up database..."
|
||||||
|
bun run db:push
|
||||||
|
log_step_end "bun run db:push"
|
||||||
|
|
||||||
|
log_step_start "Starting Next.js dev server"
|
||||||
|
echo "[BUN] Starting development server..."
|
||||||
|
bun run dev &
|
||||||
|
DEV_PID=$!
|
||||||
|
log_step_end "Starting Next.js dev server"
|
||||||
|
|
||||||
|
log_step_start "Waiting for Next.js dev server"
|
||||||
|
wait_for_service "localhost" "3000" "Next.js dev server"
|
||||||
|
log_step_end "Waiting for Next.js dev server"
|
||||||
|
|
||||||
|
log_step_start "Health check"
|
||||||
|
echo "[BUN] Performing health check..."
|
||||||
|
curl -fsS localhost:3000 >/dev/null
|
||||||
|
echo "[BUN] Health check passed"
|
||||||
|
log_step_end "Health check"
|
||||||
|
|
||||||
|
start_mini_services
|
||||||
|
|
||||||
|
echo "Next.js dev server is running in background (PID: $DEV_PID)."
|
||||||
|
echo "Use 'kill $DEV_PID' to stop it."
|
||||||
|
disown "$DEV_PID" 2>/dev/null || true
|
||||||
|
unset DEV_PID
|
||||||
78
.zscripts/mini-services-build.sh
Executable file
78
.zscripts/mini-services-build.sh
Executable file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 配置项
|
||||||
|
ROOT_DIR="/home/z/my-project/mini-services"
|
||||||
|
DIST_DIR="/tmp/build_fullstack_$BUILD_ID/mini-services-dist"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
echo "🚀 开始批量构建..."
|
||||||
|
|
||||||
|
# 检查 rootdir 是否存在
|
||||||
|
if [ ! -d "$ROOT_DIR" ]; then
|
||||||
|
echo "ℹ️ 目录 $ROOT_DIR 不存在,跳过构建"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建输出目录(如果不存在)
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
# 统计变量
|
||||||
|
success_count=0
|
||||||
|
fail_count=0
|
||||||
|
|
||||||
|
# 遍历 mini-services 目录下的所有文件夹
|
||||||
|
for dir in "$ROOT_DIR"/*; do
|
||||||
|
# 检查是否是目录且包含 package.json
|
||||||
|
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
|
||||||
|
project_name=$(basename "$dir")
|
||||||
|
|
||||||
|
# 智能查找入口文件 (按优先级查找)
|
||||||
|
entry_path=""
|
||||||
|
for entry in "src/index.ts" "index.ts" "src/index.js" "index.js"; do
|
||||||
|
if [ -f "$dir/$entry" ]; then
|
||||||
|
entry_path="$dir/$entry"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$entry_path" ]; then
|
||||||
|
echo "⚠️ 跳过 $project_name: 未找到入口文件 (index.ts/js)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📦 正在构建: $project_name..."
|
||||||
|
|
||||||
|
# 使用 bun build CLI 构建
|
||||||
|
output_file="$DIST_DIR/mini-service-$project_name.js"
|
||||||
|
|
||||||
|
if bun build "$entry_path" \
|
||||||
|
--outfile "$output_file" \
|
||||||
|
--target bun \
|
||||||
|
--minify; then
|
||||||
|
echo "✅ $project_name 构建成功 -> $output_file"
|
||||||
|
success_count=$((success_count + 1))
|
||||||
|
else
|
||||||
|
echo "❌ $project_name 构建失败"
|
||||||
|
fail_count=$((fail_count + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -f ./.zscripts/mini-services-start.sh ]; then
|
||||||
|
cp ./.zscripts/mini-services-start.sh "$DIST_DIR/mini-services-start.sh"
|
||||||
|
chmod +x "$DIST_DIR/mini-services-start.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 所有任务完成!"
|
||||||
|
if [ $success_count -gt 0 ] || [ $fail_count -gt 0 ]; then
|
||||||
|
echo "✅ 成功: $success_count 个"
|
||||||
|
if [ $fail_count -gt 0 ]; then
|
||||||
|
echo "❌ 失败: $fail_count 个"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
|
|
||||||
65
.zscripts/mini-services-install.sh
Executable file
65
.zscripts/mini-services-install.sh
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 配置项
|
||||||
|
ROOT_DIR="/home/z/my-project/mini-services"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
echo "🚀 开始批量安装依赖..."
|
||||||
|
|
||||||
|
# 检查 rootdir 是否存在
|
||||||
|
if [ ! -d "$ROOT_DIR" ]; then
|
||||||
|
echo "ℹ️ 目录 $ROOT_DIR 不存在,跳过安装"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 统计变量
|
||||||
|
success_count=0
|
||||||
|
fail_count=0
|
||||||
|
failed_projects=""
|
||||||
|
|
||||||
|
# 遍历 mini-services 目录下的所有文件夹
|
||||||
|
for dir in "$ROOT_DIR"/*; do
|
||||||
|
# 检查是否是目录且包含 package.json
|
||||||
|
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
|
||||||
|
project_name=$(basename "$dir")
|
||||||
|
echo ""
|
||||||
|
echo "📦 正在安装依赖: $project_name..."
|
||||||
|
|
||||||
|
# 进入项目目录并执行 bun install
|
||||||
|
if (cd "$dir" && bun install); then
|
||||||
|
echo "✅ $project_name 依赖安装成功"
|
||||||
|
success_count=$((success_count + 1))
|
||||||
|
else
|
||||||
|
echo "❌ $project_name 依赖安装失败"
|
||||||
|
fail_count=$((fail_count + 1))
|
||||||
|
if [ -z "$failed_projects" ]; then
|
||||||
|
failed_projects="$project_name"
|
||||||
|
else
|
||||||
|
failed_projects="$failed_projects $project_name"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 汇总结果
|
||||||
|
echo ""
|
||||||
|
echo "=================================================="
|
||||||
|
if [ $success_count -gt 0 ] || [ $fail_count -gt 0 ]; then
|
||||||
|
echo "🎉 安装完成!"
|
||||||
|
echo "✅ 成功: $success_count 个"
|
||||||
|
if [ $fail_count -gt 0 ]; then
|
||||||
|
echo "❌ 失败: $fail_count 个"
|
||||||
|
echo ""
|
||||||
|
echo "失败的项目:"
|
||||||
|
for project in $failed_projects; do
|
||||||
|
echo " - $project"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ℹ️ 未找到任何包含 package.json 的项目"
|
||||||
|
fi
|
||||||
|
echo "=================================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
|
|
||||||
123
.zscripts/mini-services-start.sh
Executable file
123
.zscripts/mini-services-start.sh
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# 配置项
|
||||||
|
DIST_DIR="./mini-services-dist"
|
||||||
|
|
||||||
|
# 存储所有子进程的 PID
|
||||||
|
pids=""
|
||||||
|
|
||||||
|
# 清理函数:优雅关闭所有服务
|
||||||
|
cleanup() {
|
||||||
|
echo ""
|
||||||
|
echo "🛑 正在关闭所有服务..."
|
||||||
|
|
||||||
|
# 发送 SIGTERM 信号给所有子进程
|
||||||
|
for pid in $pids; do
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
service_name=$(ps -p "$pid" -o comm= 2>/dev/null || echo "unknown")
|
||||||
|
echo " 关闭进程 $pid ($service_name)..."
|
||||||
|
kill -TERM "$pid" 2>/dev/null
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 等待所有进程退出(最多等待 5 秒)
|
||||||
|
sleep 1
|
||||||
|
for pid in $pids; do
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
# 如果还在运行,等待最多 4 秒
|
||||||
|
timeout=4
|
||||||
|
while [ $timeout -gt 0 ] && kill -0 "$pid" 2>/dev/null; do
|
||||||
|
sleep 1
|
||||||
|
timeout=$((timeout - 1))
|
||||||
|
done
|
||||||
|
# 如果仍然在运行,强制关闭
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo " 强制关闭进程 $pid..."
|
||||||
|
kill -KILL "$pid" 2>/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "✅ 所有服务已关闭"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
echo "🚀 开始启动所有 mini services..."
|
||||||
|
|
||||||
|
# 检查 dist 目录是否存在
|
||||||
|
if [ ! -d "$DIST_DIR" ]; then
|
||||||
|
echo "ℹ️ 目录 $DIST_DIR 不存在"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 查找所有 mini-service-*.js 文件
|
||||||
|
service_files=""
|
||||||
|
for file in "$DIST_DIR"/mini-service-*.js; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
if [ -z "$service_files" ]; then
|
||||||
|
service_files="$file"
|
||||||
|
else
|
||||||
|
service_files="$service_files $file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 计算服务文件数量
|
||||||
|
service_count=0
|
||||||
|
for file in $service_files; do
|
||||||
|
service_count=$((service_count + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $service_count -eq 0 ]; then
|
||||||
|
echo "ℹ️ 未找到任何 mini service 文件"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 找到 $service_count 个服务,开始启动..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 启动每个服务
|
||||||
|
for file in $service_files; do
|
||||||
|
service_name=$(basename "$file" .js | sed 's/mini-service-//')
|
||||||
|
echo "▶️ 启动服务: $service_name..."
|
||||||
|
|
||||||
|
# 使用 bun 运行服务(后台运行)
|
||||||
|
bun "$file" &
|
||||||
|
pid=$!
|
||||||
|
if [ -z "$pids" ]; then
|
||||||
|
pids="$pid"
|
||||||
|
else
|
||||||
|
pids="$pids $pid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 等待一小段时间检查进程是否成功启动
|
||||||
|
sleep 0.5
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo "❌ $service_name 启动失败"
|
||||||
|
# 从字符串中移除失败的 PID
|
||||||
|
pids=$(echo "$pids" | sed "s/\b$pid\b//" | sed 's/ */ /g' | sed 's/^ *//' | sed 's/ *$//')
|
||||||
|
else
|
||||||
|
echo "✅ $service_name 已启动 (PID: $pid)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 计算运行中的服务数量
|
||||||
|
running_count=0
|
||||||
|
for pid in $pids; do
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
running_count=$((running_count + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 所有服务已启动!共 $running_count 个服务正在运行"
|
||||||
|
echo ""
|
||||||
|
echo "💡 按 Ctrl+C 停止所有服务"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 等待所有后台进程
|
||||||
|
wait
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
|
|
||||||
126
.zscripts/start.sh
Executable file
126
.zscripts/start.sh
Executable file
@@ -0,0 +1,126 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 获取脚本所在目录
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
BUILD_DIR="$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# 存储所有子进程的 PID
|
||||||
|
pids=""
|
||||||
|
|
||||||
|
# 清理函数:优雅关闭所有服务
|
||||||
|
cleanup() {
|
||||||
|
echo ""
|
||||||
|
echo "🛑 正在关闭所有服务..."
|
||||||
|
|
||||||
|
# 发送 SIGTERM 信号给所有子进程
|
||||||
|
for pid in $pids; do
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
service_name=$(ps -p "$pid" -o comm= 2>/dev/null || echo "unknown")
|
||||||
|
echo " 关闭进程 $pid ($service_name)..."
|
||||||
|
kill -TERM "$pid" 2>/dev/null
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 等待所有进程退出(最多等待 5 秒)
|
||||||
|
sleep 1
|
||||||
|
for pid in $pids; do
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
# 如果还在运行,等待最多 4 秒
|
||||||
|
timeout=4
|
||||||
|
while [ $timeout -gt 0 ] && kill -0 "$pid" 2>/dev/null; do
|
||||||
|
sleep 1
|
||||||
|
timeout=$((timeout - 1))
|
||||||
|
done
|
||||||
|
# 如果仍然在运行,强制关闭
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo " 强制关闭进程 $pid..."
|
||||||
|
kill -KILL "$pid" 2>/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "✅ 所有服务已关闭"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "🚀 开始启动所有服务..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 切换到构建目录
|
||||||
|
cd "$BUILD_DIR" || exit 1
|
||||||
|
|
||||||
|
ls -lah
|
||||||
|
|
||||||
|
# 初始化数据库(如果存在)
|
||||||
|
if [ -d "./next-service-dist/db" ] && [ "$(ls -A ./next-service-dist/db 2>/dev/null)" ] && [ -d "/db" ]; then
|
||||||
|
echo "🗄️ 初始化数据库从 ./next-service-dist/db 到 /db..."
|
||||||
|
cp -r ./next-service-dist/db/* /db/ 2>/dev/null || echo " ⚠️ 无法复制到 /db,跳过数据库初始化"
|
||||||
|
echo "✅ 数据库初始化完成"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动 Next.js 服务器
|
||||||
|
if [ -f "./next-service-dist/server.js" ]; then
|
||||||
|
echo "🚀 启动 Next.js 服务器..."
|
||||||
|
cd next-service-dist/ || exit 1
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
export NODE_ENV=production
|
||||||
|
export PORT=${PORT:-3000}
|
||||||
|
export HOSTNAME=${HOSTNAME:-0.0.0.0}
|
||||||
|
|
||||||
|
# 后台启动 Next.js
|
||||||
|
bun server.js &
|
||||||
|
NEXT_PID=$!
|
||||||
|
pids="$NEXT_PID"
|
||||||
|
|
||||||
|
# 等待一小段时间检查进程是否成功启动
|
||||||
|
sleep 1
|
||||||
|
if ! kill -0 "$NEXT_PID" 2>/dev/null; then
|
||||||
|
echo "❌ Next.js 服务器启动失败"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ Next.js 服务器已启动 (PID: $NEXT_PID, Port: $PORT)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd ../
|
||||||
|
else
|
||||||
|
echo "⚠️ 未找到 Next.js 服务器文件: ./next-service-dist/server.js"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动 mini-services
|
||||||
|
if [ -f "./mini-services-start.sh" ]; then
|
||||||
|
echo "🚀 启动 mini-services..."
|
||||||
|
|
||||||
|
# 运行启动脚本(从根目录运行,脚本内部会处理 mini-services-dist 目录)
|
||||||
|
sh ./mini-services-start.sh &
|
||||||
|
MINI_PID=$!
|
||||||
|
pids="$pids $MINI_PID"
|
||||||
|
|
||||||
|
# 等待一小段时间检查进程是否成功启动
|
||||||
|
sleep 1
|
||||||
|
if ! kill -0 "$MINI_PID" 2>/dev/null; then
|
||||||
|
echo "⚠️ mini-services 可能启动失败,但继续运行..."
|
||||||
|
else
|
||||||
|
echo "✅ mini-services 已启动 (PID: $MINI_PID)"
|
||||||
|
fi
|
||||||
|
elif [ -d "./mini-services-dist" ]; then
|
||||||
|
echo "⚠️ 未找到 mini-services 启动脚本,但目录存在"
|
||||||
|
else
|
||||||
|
echo "ℹ️ mini-services 目录不存在,跳过"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动 Caddy(如果存在 Caddyfile)
|
||||||
|
echo "🚀 启动 Caddy..."
|
||||||
|
|
||||||
|
# Caddy 作为前台进程运行(主进程)
|
||||||
|
echo "✅ Caddy 已启动(前台运行)"
|
||||||
|
echo ""
|
||||||
|
echo "🎉 所有服务已启动!"
|
||||||
|
echo ""
|
||||||
|
echo "💡 按 Ctrl+C 停止所有服务"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Caddy 作为主进程运行
|
||||||
|
exec caddy run --config Caddyfile --adapter caddyfile
|
||||||
431
AGENTS.md
Executable file
431
AGENTS.md
Executable file
@@ -0,0 +1,431 @@
|
|||||||
|
# Mana Loop - Project Architecture Guide
|
||||||
|
|
||||||
|
This document provides a comprehensive overview of the project architecture for AI agents working on this codebase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Git Credentials (SAVE THESE)
|
||||||
|
|
||||||
|
**Repository:** `git@gitea.tailf367e3.ts.net:Anexim/Mana-Loop.git`
|
||||||
|
|
||||||
|
**HTTPS URL with credentials:**
|
||||||
|
```
|
||||||
|
https://zhipu:5LlnutmdsC2WirDwWgnZuRH7@gitea.tailf367e3.ts.net/Anexim/Mana-Loop.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Credentials:**
|
||||||
|
- **User:** zhipu
|
||||||
|
- **Email:** zhipu@local.local
|
||||||
|
- **Password:** 5LlnutmdsC2WirDwWgnZuRH7
|
||||||
|
|
||||||
|
**To configure git:**
|
||||||
|
```bash
|
||||||
|
git config --global user.name "zhipu"
|
||||||
|
git config --global user.email "zhipu@local.local"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ MANDATORY GIT WORKFLOW - MUST BE FOLLOWED
|
||||||
|
|
||||||
|
**Before starting ANY work, you MUST:**
|
||||||
|
|
||||||
|
1. **Pull the latest changes:**
|
||||||
|
```bash
|
||||||
|
cd /home/z/my-project && git pull origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Do your task** - Make all necessary code changes
|
||||||
|
|
||||||
|
3. **Before finishing, commit and push:**
|
||||||
|
```bash
|
||||||
|
cd /home/z/my-project
|
||||||
|
git add -A
|
||||||
|
git commit -m "descriptive message about changes"
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
**This workflow is ENFORCED and NON-NEGOTIABLE.** Every agent session must:
|
||||||
|
- Start with `git pull`
|
||||||
|
- End with `git add`, `git commit`, `git push`
|
||||||
|
|
||||||
|
**Git Remote:** `git@gitea.tailf367e3.ts.net:Anexim/Mana-Loop.git`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Mana Loop** is an incremental/idle game built with:
|
||||||
|
- **Framework**: Next.js 16 with App Router
|
||||||
|
- **Language**: TypeScript 5
|
||||||
|
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
||||||
|
- **State Management**: Zustand with persist middleware
|
||||||
|
- **Database**: Prisma ORM with SQLite (for persistence features)
|
||||||
|
|
||||||
|
## Core Game Loop
|
||||||
|
|
||||||
|
1. **Mana Gathering**: Click or auto-generate mana over time
|
||||||
|
2. **Studying**: Spend mana to learn skills and spells
|
||||||
|
3. **Combat**: Climb the Spire, defeat guardians, sign pacts
|
||||||
|
4. **Crafting**: Enchant equipment with spell effects
|
||||||
|
5. **Prestige**: Reset progress for permanent bonuses (Insight)
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── page.tsx # Main game UI (~1700 lines, single page application)
|
||||||
|
│ ├── layout.tsx # Root layout with providers
|
||||||
|
│ └── api/ # API routes (minimal use)
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn/ui components (auto-generated)
|
||||||
|
│ └── game/
|
||||||
|
│ ├── index.ts # Barrel exports
|
||||||
|
│ ├── ActionButtons.tsx # Main action buttons (Meditate, Climb, Study, etc.)
|
||||||
|
│ ├── CalendarDisplay.tsx # Day calendar with incursion indicators
|
||||||
|
│ ├── CraftingProgress.tsx # Design/preparation/application progress bars
|
||||||
|
│ ├── StudyProgress.tsx # Current study progress with cancel button
|
||||||
|
│ ├── ManaDisplay.tsx # Mana/gathering section with progress bar
|
||||||
|
│ ├── TimeDisplay.tsx # Day/hour display with pause toggle
|
||||||
|
│ └── tabs/ # Tab-specific components
|
||||||
|
│ ├── index.ts # Tab component exports
|
||||||
|
│ ├── CraftingTab.tsx # Enchantment crafting UI
|
||||||
|
│ ├── LabTab.tsx # Skill upgrade and lab features
|
||||||
|
│ ├── SpellsTab.tsx # Spell management and equipment spells
|
||||||
|
│ └── SpireTab.tsx # Combat and spire climbing
|
||||||
|
└── lib/
|
||||||
|
├── game/
|
||||||
|
│ ├── store.ts # Zustand store (~1650 lines, main state + tick logic)
|
||||||
|
│ ├── computed-stats.ts # Computed stats functions (extracted utilities)
|
||||||
|
│ ├── navigation-slice.ts # Floor navigation actions (setClimbDirection, changeFloor)
|
||||||
|
│ ├── study-slice.ts # Study system actions (startStudying*, cancelStudy)
|
||||||
|
│ ├── crafting-slice.ts # Equipment/enchantment logic
|
||||||
|
│ ├── familiar-slice.ts # Familiar system actions
|
||||||
|
│ ├── effects.ts # Unified effect computation
|
||||||
|
│ ├── upgrade-effects.ts # Skill upgrade effect definitions
|
||||||
|
│ ├── constants.ts # Game definitions (spells, skills, etc.)
|
||||||
|
│ ├── skill-evolution.ts # Skill tier progression paths
|
||||||
|
│ ├── types.ts # TypeScript interfaces
|
||||||
|
│ ├── formatting.ts # Display formatters
|
||||||
|
│ ├── utils.ts # Utility functions
|
||||||
|
│ └── data/
|
||||||
|
│ ├── equipment.ts # Equipment type definitions
|
||||||
|
│ └── enchantment-effects.ts # Enchantment effect catalog
|
||||||
|
└── utils.ts # General utilities (cn function)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Systems
|
||||||
|
|
||||||
|
### 1. State Management (`store.ts`)
|
||||||
|
|
||||||
|
The game uses a Zustand store organized with **slice pattern** for better maintainability:
|
||||||
|
|
||||||
|
#### Store Slices
|
||||||
|
- **Main Store** (`store.ts`): Core state, tick logic, and main actions
|
||||||
|
- **Navigation Slice** (`navigation-slice.ts`): Floor navigation (setClimbDirection, changeFloor)
|
||||||
|
- **Study Slice** (`study-slice.ts`): Study system (startStudyingSkill, startStudyingSpell, cancelStudy)
|
||||||
|
- **Crafting Slice** (`crafting-slice.ts`): Equipment/enchantment (createEquipmentInstance, startDesigningEnchantment)
|
||||||
|
- **Familiar Slice** (`familiar-slice.ts`): Familiar system (addFamiliar, removeFamiliar)
|
||||||
|
|
||||||
|
#### Computed Stats (`computed-stats.ts`)
|
||||||
|
Extracted utility functions for stat calculations:
|
||||||
|
- `computeMaxMana()`, `computeRegen()`, `computeEffectiveRegen()`
|
||||||
|
- `calcDamage()`, `calcInsight()`, `getElementalBonus()`
|
||||||
|
- `getFloorMaxHP()`, `getFloorElement()`, `getMeditationBonus()`
|
||||||
|
- `canAffordSpellCost()`, `deductSpellCost()`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GameState {
|
||||||
|
// Time
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
paused: boolean;
|
||||||
|
|
||||||
|
// Mana
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, ElementState>;
|
||||||
|
|
||||||
|
// Combat
|
||||||
|
currentFloor: number;
|
||||||
|
floorHP: number;
|
||||||
|
activeSpell: string;
|
||||||
|
castProgress: number;
|
||||||
|
|
||||||
|
// Progression
|
||||||
|
skills: Record<string, number>;
|
||||||
|
spells: Record<string, SpellState>;
|
||||||
|
skillUpgrades: Record<string, string[]>;
|
||||||
|
skillTiers: Record<string, number>;
|
||||||
|
|
||||||
|
// Equipment
|
||||||
|
equipmentInstances: Record<string, EquipmentInstance>;
|
||||||
|
equippedInstances: Record<string, string | null>;
|
||||||
|
enchantmentDesigns: EnchantmentDesign[];
|
||||||
|
|
||||||
|
// Prestige
|
||||||
|
insight: number;
|
||||||
|
prestigeUpgrades: Record<string, number>;
|
||||||
|
signedPacts: number[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Effect System (`effects.ts`)
|
||||||
|
|
||||||
|
**CRITICAL**: All stat modifications flow through the unified effect system.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Effects come from two sources:
|
||||||
|
// 1. Skill Upgrades (milestone bonuses)
|
||||||
|
// 2. Equipment Enchantments (crafted bonuses)
|
||||||
|
|
||||||
|
getUnifiedEffects(state) => UnifiedEffects {
|
||||||
|
maxManaBonus, maxManaMultiplier,
|
||||||
|
regenBonus, regenMultiplier,
|
||||||
|
clickManaBonus, clickManaMultiplier,
|
||||||
|
baseDamageBonus, baseDamageMultiplier,
|
||||||
|
attackSpeedMultiplier,
|
||||||
|
critChanceBonus, critDamageMultiplier,
|
||||||
|
studySpeedMultiplier,
|
||||||
|
specials: Set<string>, // Special effect IDs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When adding new stats**:
|
||||||
|
1. Add to `ComputedEffects` interface in `upgrade-effects.ts`
|
||||||
|
2. Add mapping in `computeEquipmentEffects()` in `effects.ts`
|
||||||
|
3. Apply in the relevant game logic (tick, damage calc, etc.)
|
||||||
|
|
||||||
|
### 3. Combat System
|
||||||
|
|
||||||
|
Combat uses a **cast speed** system:
|
||||||
|
- Each spell has `castSpeed` (casts per hour)
|
||||||
|
- Cast progress accumulates: `progress += castSpeed * attackSpeedMultiplier * HOURS_PER_TICK`
|
||||||
|
- When `progress >= 1`, spell is cast (cost deducted, damage dealt)
|
||||||
|
- DPS = `damagePerCast * castsPerSecond`
|
||||||
|
|
||||||
|
Damage calculation order:
|
||||||
|
1. Base spell damage
|
||||||
|
2. Skill bonuses (combatTrain, arcaneFury, etc.)
|
||||||
|
3. Upgrade effects (multipliers, bonuses)
|
||||||
|
4. Special effects (Overpower, Berserker, etc.)
|
||||||
|
5. Elemental modifiers (same element +25%, super effective +50%)
|
||||||
|
|
||||||
|
### 4. Crafting/Enchantment System
|
||||||
|
|
||||||
|
Three-stage process:
|
||||||
|
1. **Design**: Select effects, takes time based on complexity
|
||||||
|
2. **Prepare**: Pay mana to prepare equipment, takes time
|
||||||
|
3. **Apply**: Apply design to equipment, costs mana per hour
|
||||||
|
|
||||||
|
Equipment has **capacity** that limits total enchantment power.
|
||||||
|
|
||||||
|
### 5. Skill Evolution System
|
||||||
|
|
||||||
|
Skills have 5 tiers of evolution:
|
||||||
|
- At level 5: Choose 2 of 4 milestone upgrades
|
||||||
|
- At level 10: Choose 2 more upgrades, then tier up
|
||||||
|
- Each tier multiplies the skill's base effect by 10x
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### Adding a New Effect
|
||||||
|
|
||||||
|
1. **Define in `enchantment-effects.ts`**:
|
||||||
|
```typescript
|
||||||
|
my_new_effect: {
|
||||||
|
id: 'my_new_effect',
|
||||||
|
name: 'Effect Name',
|
||||||
|
description: '+10% something',
|
||||||
|
category: 'combat',
|
||||||
|
baseCapacityCost: 30,
|
||||||
|
maxStacks: 3,
|
||||||
|
allowedEquipmentCategories: ['caster', 'hands'],
|
||||||
|
effect: { type: 'multiplier', stat: 'attackSpeed', value: 1.10 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add stat mapping in `effects.ts`** (if new stat):
|
||||||
|
```typescript
|
||||||
|
// In computeEquipmentEffects()
|
||||||
|
if (effect.stat === 'myNewStat') {
|
||||||
|
bonuses.myNewStat = (bonuses.myNewStat || 0) + effect.value;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Apply in game logic**:
|
||||||
|
```typescript
|
||||||
|
const effects = getUnifiedEffects(state);
|
||||||
|
damage *= effects.myNewStatMultiplier;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Skill
|
||||||
|
|
||||||
|
1. **Define in `constants.ts` SKILLS_DEF**
|
||||||
|
2. **Add evolution path in `skill-evolution.ts`**
|
||||||
|
3. **Add prerequisite checks in `store.ts`**
|
||||||
|
4. **Update UI in `page.tsx`**
|
||||||
|
|
||||||
|
### Adding a New Spell
|
||||||
|
|
||||||
|
1. **Define in `constants.ts` SPELLS_DEF**
|
||||||
|
2. **Add spell enchantment in `enchantment-effects.ts`**
|
||||||
|
3. **Add research skill in `constants.ts`**
|
||||||
|
4. **Map research to effect in `EFFECT_RESEARCH_MAPPING`**
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Forgetting to call `getUnifiedEffects()`**: Always use unified effects for stat calculations
|
||||||
|
2. **Direct stat modification**: Never modify stats directly; use effect system
|
||||||
|
3. **Missing tier multiplier**: Use `getTierMultiplier(skillId)` for tiered skills
|
||||||
|
4. **Ignoring special effects**: Check `hasSpecial(effects, SPECIAL_EFFECTS.X)` for special abilities
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
- Run `bun run lint` after changes
|
||||||
|
- Check dev server logs at `/home/z/my-project/dev.log`
|
||||||
|
- Test with fresh game state (clear localStorage)
|
||||||
|
|
||||||
|
## Slice Pattern for Store Organization
|
||||||
|
|
||||||
|
The store uses a **slice pattern** to organize related actions into separate files. This improves maintainability and makes the codebase more modular.
|
||||||
|
|
||||||
|
### Creating a New Slice
|
||||||
|
|
||||||
|
1. **Create the slice file** (e.g., `my-feature-slice.ts`):
|
||||||
|
```typescript
|
||||||
|
// Define the actions interface
|
||||||
|
export interface MyFeatureActions {
|
||||||
|
doSomething: (param: string) => void;
|
||||||
|
undoSomething: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the slice factory
|
||||||
|
export function createMyFeatureSlice(
|
||||||
|
set: StoreApi<GameStore>['setState'],
|
||||||
|
get: StoreApi<GameStore>['getState']
|
||||||
|
): MyFeatureActions {
|
||||||
|
return {
|
||||||
|
doSomething: (param: string) => {
|
||||||
|
set((state) => {
|
||||||
|
// Update state
|
||||||
|
});
|
||||||
|
},
|
||||||
|
undoSomething: () => {
|
||||||
|
set((state) => {
|
||||||
|
// Update state
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add to main store** (`store.ts`):
|
||||||
|
```typescript
|
||||||
|
import { createMyFeatureSlice, MyFeatureActions } from './my-feature-slice';
|
||||||
|
|
||||||
|
// Extend GameStore interface
|
||||||
|
interface GameStore extends GameState, MyFeatureActions, /* other slices */ {}
|
||||||
|
|
||||||
|
// Spread into store creation
|
||||||
|
const useGameStore = create<GameStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
...createMyFeatureSlice(set, get),
|
||||||
|
// other slices and state
|
||||||
|
}),
|
||||||
|
// persist config
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing Slices
|
||||||
|
|
||||||
|
| Slice | File | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| Navigation | `navigation-slice.ts` | Floor navigation (setClimbDirection, changeFloor) |
|
||||||
|
| Study | `study-slice.ts` | Study system (startStudyingSkill, startStudyingSpell, cancelStudy) |
|
||||||
|
| Crafting | `crafting-slice.ts` | Equipment/enchantment (createEquipmentInstance, startDesigningEnchantment) |
|
||||||
|
| Familiar | `familiar-slice.ts` | Familiar system (addFamiliar, removeFamiliar) |
|
||||||
|
|
||||||
|
## File Size Guidelines
|
||||||
|
|
||||||
|
### Current File Sizes (After Refactoring)
|
||||||
|
| File | Lines | Notes |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `store.ts` | ~1650 | Core state + tick logic (reduced from 2138, 23% reduction) |
|
||||||
|
| `page.tsx` | ~1695 | Main UI (reduced from 2554, 34% reduction) |
|
||||||
|
| `computed-stats.ts` | ~200 | Extracted utility functions |
|
||||||
|
| `navigation-slice.ts` | ~50 | Navigation actions |
|
||||||
|
| `study-slice.ts` | ~100 | Study system actions |
|
||||||
|
|
||||||
|
### Guidelines
|
||||||
|
- Keep `page.tsx` under 2000 lines by extracting to components (ActionButtons, ManaDisplay, etc.)
|
||||||
|
- Keep `store.ts` under 1800 lines by extracting to slices (navigation, study, crafting, familiar)
|
||||||
|
- Extract computed stats and utility functions to `computed-stats.ts` when >50 lines
|
||||||
|
- Use barrel exports (`index.ts`) for clean imports
|
||||||
|
- Follow the slice pattern for store organization (see below)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚫 BANNED CONTENT - NEVER ADD THESE
|
||||||
|
|
||||||
|
### Lifesteal and Healing are BANNED
|
||||||
|
**DO NOT add lifesteal or healing mechanics to player abilities.**
|
||||||
|
|
||||||
|
This includes:
|
||||||
|
- `lifesteal` spell effects
|
||||||
|
- `heal` or `regeneration` abilities for the player
|
||||||
|
- Any mechanic that restores player HP or mana based on damage dealt
|
||||||
|
- Life-stealing weapons or enchantments
|
||||||
|
|
||||||
|
**Rationale**: The game's core design is that the player cannot take damage - only floors can. Healing/lifesteal mechanics are unnecessary and would create confusing gameplay.
|
||||||
|
|
||||||
|
### Banned Mana Types
|
||||||
|
The following mana types have been **removed** and should **never be re-added**:
|
||||||
|
- `life` - Healing/lifesteal themed (banned)
|
||||||
|
- `blood` - Life + Water compound (banned due to lifesteal theme)
|
||||||
|
- `wood` - Life + Earth compound (banned due to life connection)
|
||||||
|
- `mental` - Mind/psionic themed (removed for design consistency)
|
||||||
|
- `force` - Telekinetic themed (removed for design consistency)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔮 Mana Types Overview
|
||||||
|
|
||||||
|
### Base Mana Types (7)
|
||||||
|
| Element | Symbol | Color | Theme |
|
||||||
|
|---------|--------|-------|-------|
|
||||||
|
| Fire | 🔥 | #FF6B35 | Destruction, burn damage |
|
||||||
|
| Water | 💧 | #4ECDC4 | Flow, freeze effects |
|
||||||
|
| Air | 🌬️ | #00D4FF | Speed, wind damage |
|
||||||
|
| Earth | ⛰️ | #F4A261 | Stability, armor pierce |
|
||||||
|
| Light | ☀️ | #FFD700 | Radiance, holy damage |
|
||||||
|
| Dark | 🌑 | #9B59B6 | Shadows, void damage |
|
||||||
|
| Death | 💀 | #778CA3 | Decay, rot damage |
|
||||||
|
|
||||||
|
### Utility Mana Types (1)
|
||||||
|
| Element | Symbol | Color | Theme |
|
||||||
|
|---------|--------|-------|-------|
|
||||||
|
| Transference | 🔗 | #1ABC9C | Mana transfer, Enchanter attunement |
|
||||||
|
|
||||||
|
### Compound Mana Types (3)
|
||||||
|
| Element | Recipe | Theme |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Metal | Fire + Earth | Armor piercing, forged weapons |
|
||||||
|
| Sand | Earth + Water | AOE damage, desert winds |
|
||||||
|
| Lightning | Fire + Air | Fast damage, armor pierce, chain effects |
|
||||||
|
|
||||||
|
### Exotic Mana Types (3)
|
||||||
|
| Element | Recipe | Theme |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Crystal | Sand + Sand + Light | Prismatic, high damage |
|
||||||
|
| Stellar | Fire + Fire + Light | Cosmic, ultimate fire/light |
|
||||||
|
| Void | Dark + Dark + Death | Oblivion, ultimate dark/death |
|
||||||
|
|
||||||
|
### Mana Type Hierarchy
|
||||||
|
```
|
||||||
|
Base Elements (7) → Compound (3) → Exotic (3)
|
||||||
|
↓
|
||||||
|
Utility (1) ← Special attunement-based
|
||||||
|
```
|
||||||
313
AUDIT_REPORT.md
Executable file
313
AUDIT_REPORT.md
Executable file
@@ -0,0 +1,313 @@
|
|||||||
|
# Mana Loop - Codebase Audit Report
|
||||||
|
|
||||||
|
**Task ID:** 4
|
||||||
|
**Date:** Audit of unimplemented effects, upgrades, and missing functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Special Effects Status
|
||||||
|
|
||||||
|
### SPECIAL_EFFECTS Constant (upgrade-effects.ts)
|
||||||
|
|
||||||
|
The `SPECIAL_EFFECTS` constant defines 32 special effect IDs. Here's the implementation status:
|
||||||
|
|
||||||
|
| Effect ID | Name | Status | Notes |
|
||||||
|
|-----------|------|--------|-------|
|
||||||
|
| `MANA_CASCADE` | Mana Cascade | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but that function is NOT called from store.ts |
|
||||||
|
| `STEADY_STREAM` | Steady Stream | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but not called from tick |
|
||||||
|
| `MANA_TORRENT` | Mana Torrent | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but not called |
|
||||||
|
| `FLOW_SURGE` | Flow Surge | ❌ Missing | Not implemented anywhere |
|
||||||
|
| `MANA_EQUILIBRIUM` | Mana Equilibrium | ❌ Missing | Not implemented |
|
||||||
|
| `DESPERATE_WELLS` | Desperate Wells | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but not called |
|
||||||
|
| `MANA_ECHO` | Mana Echo | ❌ Missing | Not implemented in gatherMana() |
|
||||||
|
| `EMERGENCY_RESERVE` | Emergency Reserve | ❌ Missing | Not implemented in startNewLoop() |
|
||||||
|
| `BATTLE_FURY` | Battle Fury | ⚠️ Partially Implemented | In `computeDynamicDamage()` but function not called |
|
||||||
|
| `ARMOR_PIERCE` | Armor Pierce | ❌ Missing | Floor defense not implemented |
|
||||||
|
| `OVERPOWER` | Overpower | ✅ Implemented | store.ts line 627 |
|
||||||
|
| `BERSERKER` | Berserker | ✅ Implemented | store.ts line 632 |
|
||||||
|
| `COMBO_MASTER` | Combo Master | ❌ Missing | Not implemented |
|
||||||
|
| `ADRENALINE_RUSH` | Adrenaline Rush | ❌ Missing | Not implemented on enemy defeat |
|
||||||
|
| `PERFECT_MEMORY` | Perfect Memory | ❌ Missing | Not implemented in cancel study |
|
||||||
|
| `QUICK_MASTERY` | Quick Mastery | ❌ Missing | Not implemented |
|
||||||
|
| `PARALLEL_STUDY` | Parallel Study | ⚠️ Partially Implemented | State exists but logic incomplete |
|
||||||
|
| `STUDY_INSIGHT` | Study Insight | ❌ Missing | Not implemented |
|
||||||
|
| `STUDY_MOMENTUM` | Study Momentum | ❌ Missing | Not implemented |
|
||||||
|
| `KNOWLEDGE_ECHO` | Knowledge Echo | ❌ Missing | Not implemented |
|
||||||
|
| `KNOWLEDGE_TRANSFER` | Knowledge Transfer | ❌ Missing | Not implemented |
|
||||||
|
| `MENTAL_CLARITY` | Mental Clarity | ❌ Missing | Not implemented |
|
||||||
|
| `STUDY_REFUND` | Study Refund | ❌ Missing | Not implemented |
|
||||||
|
| `FREE_STUDY` | Free Study | ❌ Missing | Not implemented |
|
||||||
|
| `MIND_PALACE` | Mind Palace | ❌ Missing | Not implemented |
|
||||||
|
| `STUDY_RUSH` | Study Rush | ❌ Missing | Not implemented |
|
||||||
|
| `CHAIN_STUDY` | Chain Study | ❌ Missing | Not implemented |
|
||||||
|
| `ELEMENTAL_HARMONY` | Elemental Harmony | ❌ Missing | Not implemented |
|
||||||
|
| `DEEP_STORAGE` | Deep Storage | ❌ Missing | Not implemented |
|
||||||
|
| `DOUBLE_CRAFT` | Double Craft | ❌ Missing | Not implemented |
|
||||||
|
| `ELEMENTAL_RESONANCE` | Elemental Resonance | ❌ Missing | Not implemented |
|
||||||
|
| `PURE_ELEMENTS` | Pure Elements | ❌ Missing | Not implemented |
|
||||||
|
|
||||||
|
**Summary:** 2 fully implemented, 6 partially implemented (function exists but not called), 24 not implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Enchantment Effects Status
|
||||||
|
|
||||||
|
### Equipment Enchantment Effects (enchantment-effects.ts)
|
||||||
|
|
||||||
|
The following effect types are defined:
|
||||||
|
|
||||||
|
| Effect Type | Status | Notes |
|
||||||
|
|-------------|--------|-------|
|
||||||
|
| **Spell Effects** (`type: 'spell'`) | ✅ Working | Spells granted via `getSpellsFromEquipment()` |
|
||||||
|
| **Bonus Effects** (`type: 'bonus'`) | ✅ Working | Applied in `computeEquipmentEffects()` |
|
||||||
|
| **Multiplier Effects** (`type: 'multiplier'`) | ✅ Working | Applied in `computeEquipmentEffects()` |
|
||||||
|
| **Special Effects** (`type: 'special'`) | ⚠️ Tracked Only | Added to `specials` Set but NOT applied in game logic |
|
||||||
|
|
||||||
|
### Special Enchantment Effects Not Applied:
|
||||||
|
|
||||||
|
| Effect ID | Description | Issue |
|
||||||
|
|-----------|-------------|-------|
|
||||||
|
| `spellEcho10` | 10% chance cast twice | Tracked but not implemented in combat |
|
||||||
|
| `lifesteal5` | 5% damage as mana | Tracked but not implemented in combat |
|
||||||
|
| `overpower` | +50% damage at 80% mana | Tracked but separate from skill upgrade version |
|
||||||
|
|
||||||
|
**Location of Issue:**
|
||||||
|
```typescript
|
||||||
|
// effects.ts line 58-60
|
||||||
|
} else if (effect.type === 'special' && effect.specialId) {
|
||||||
|
specials.add(effect.specialId);
|
||||||
|
}
|
||||||
|
// Effect is tracked but never used in combat/damage calculations
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Skill Effects Status
|
||||||
|
|
||||||
|
### SKILLS_DEF Analysis (constants.ts)
|
||||||
|
|
||||||
|
Skills with direct effects that should apply per level:
|
||||||
|
|
||||||
|
| Skill | Effect | Status |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| `manaWell` | +100 max mana per level | ✅ Implemented |
|
||||||
|
| `manaFlow` | +1 regen/hr per level | ✅ Implemented |
|
||||||
|
| `elemAttune` | +50 elem mana cap | ✅ Implemented |
|
||||||
|
| `manaOverflow` | +25% click mana | ✅ Implemented |
|
||||||
|
| `quickLearner` | +10% study speed | ✅ Implemented |
|
||||||
|
| `focusedMind` | -5% study cost | ✅ Implemented |
|
||||||
|
| `meditation` | 2.5x regen after 4hrs | ✅ Implemented |
|
||||||
|
| `knowledgeRetention` | +20% progress saved | ⚠️ Partially Implemented |
|
||||||
|
| `enchanting` | Unlocks designs | ✅ Implemented |
|
||||||
|
| `efficientEnchant` | -5% capacity cost | ⚠️ Not verified |
|
||||||
|
| `disenchanting` | 20% mana recovery | ⚠️ Not verified |
|
||||||
|
| `enchantSpeed` | -10% enchant time | ⚠️ Not verified |
|
||||||
|
| `scrollCrafting` | Create scrolls | ❌ Not implemented |
|
||||||
|
| `essenceRefining` | +10% effect power | ⚠️ Not verified |
|
||||||
|
| `effCrafting` | -10% craft time | ⚠️ Not verified |
|
||||||
|
| `fieldRepair` | +15% repair | ❌ Repair not implemented |
|
||||||
|
| `elemCrafting` | +25% craft output | ✅ Implemented |
|
||||||
|
| `manaTap` | +1 mana/click | ✅ Implemented |
|
||||||
|
| `manaSurge` | +3 mana/click | ✅ Implemented |
|
||||||
|
| `manaSpring` | +2 regen | ✅ Implemented |
|
||||||
|
| `deepTrance` | 3x after 6hrs | ✅ Implemented |
|
||||||
|
| `voidMeditation` | 5x after 8hrs | ✅ Implemented |
|
||||||
|
| `insightHarvest` | +10% insight | ✅ Implemented |
|
||||||
|
| `temporalMemory` | Keep spells | ✅ Implemented |
|
||||||
|
| `guardianBane` | +20% vs guardians | ⚠️ Tracked but not verified |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Missing Implementations
|
||||||
|
|
||||||
|
### 4.1 Dynamic Effect Functions Not Called
|
||||||
|
|
||||||
|
The following functions exist in `upgrade-effects.ts` but are NOT called from `store.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// upgrade-effects.ts - EXISTS but NOT USED
|
||||||
|
export function computeDynamicRegen(
|
||||||
|
effects: ComputedEffects,
|
||||||
|
baseRegen: number,
|
||||||
|
maxMana: number,
|
||||||
|
currentMana: number,
|
||||||
|
incursionStrength: number
|
||||||
|
): number { ... }
|
||||||
|
|
||||||
|
export function computeDynamicDamage(
|
||||||
|
effects: ComputedEffects,
|
||||||
|
baseDamage: number,
|
||||||
|
floorHPPct: number,
|
||||||
|
currentMana: number,
|
||||||
|
maxMana: number,
|
||||||
|
consecutiveHits: number
|
||||||
|
): number { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Where it should be called:**
|
||||||
|
- `store.ts` tick() function around line 414 for regen
|
||||||
|
- `store.ts` tick() function around line 618 for damage
|
||||||
|
|
||||||
|
### 4.2 Missing Combat Special Effects
|
||||||
|
|
||||||
|
Location: `store.ts` tick() combat section (lines 510-760)
|
||||||
|
|
||||||
|
Missing implementations:
|
||||||
|
```typescript
|
||||||
|
// BATTLE_FURY - +10% damage per consecutive hit
|
||||||
|
if (hasSpecial(effects, SPECIAL_EFFECTS.BATTLE_FURY)) {
|
||||||
|
// Need to track consecutiveHits in state
|
||||||
|
}
|
||||||
|
|
||||||
|
// ARMOR_PIERCE - Ignore 10% floor defense
|
||||||
|
// Floor defense not implemented in game
|
||||||
|
|
||||||
|
// COMBO_MASTER - Every 5th attack deals 3x damage
|
||||||
|
if (hasSpecial(effects, SPECIAL_EFFECTS.COMBO_MASTER)) {
|
||||||
|
// Need to track hitCount in state
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADRENALINE_RUSH - Restore 5% mana on kill
|
||||||
|
// Should be added after floorHP <= 0 check
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Missing Study Special Effects
|
||||||
|
|
||||||
|
Location: `store.ts` tick() study section (lines 440-485)
|
||||||
|
|
||||||
|
Missing implementations:
|
||||||
|
```typescript
|
||||||
|
// MENTAL_CLARITY - +10% study speed when mana > 75%
|
||||||
|
// STUDY_RUSH - First hour is 2x speed
|
||||||
|
// STUDY_REFUND - 25% mana back on completion
|
||||||
|
// KNOWLEDGE_ECHO - 10% instant study chance
|
||||||
|
// STUDY_MOMENTUM - +5% speed per consecutive hour
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Missing Loop/Click Effects
|
||||||
|
|
||||||
|
Location: `store.ts` gatherMana() and startNewLoop()
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// gatherMana() - MANA_ECHO
|
||||||
|
// 10% chance to gain double mana from clicks
|
||||||
|
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_ECHO) && Math.random() < 0.1) {
|
||||||
|
cm *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// startNewLoop() - EMERGENCY_RESERVE
|
||||||
|
// Keep 10% max mana when starting new loop
|
||||||
|
if (hasSpecial(effects, SPECIAL_EFFECTS.EMERGENCY_RESERVE)) {
|
||||||
|
newState.rawMana = maxMana * 0.1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Parallel Study Incomplete
|
||||||
|
|
||||||
|
`parallelStudyTarget` exists in state but the logic is not fully implemented in tick():
|
||||||
|
- State field exists (line 203)
|
||||||
|
- No tick processing for parallel study
|
||||||
|
- UI may show it but actual progress not processed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Balance Concerns
|
||||||
|
|
||||||
|
### 5.1 Weak Upgrades
|
||||||
|
|
||||||
|
| Upgrade | Issue | Suggestion |
|
||||||
|
|---------|-------|------------|
|
||||||
|
| `manaThreshold` | +20% mana for -10% regen is a net negative early | Change to +30% mana for -5% regen |
|
||||||
|
| `manaOverflow` | +25% click mana at 5 levels is only +5%/level | Increase to +10% per level |
|
||||||
|
| `fieldRepair` | Repair system not implemented | Remove or implement repair |
|
||||||
|
| `scrollCrafting` | Scroll system not implemented | Remove or implement scrolls |
|
||||||
|
|
||||||
|
### 5.2 Tier Scaling Issues
|
||||||
|
|
||||||
|
From `skill-evolution.ts`, tier multipliers are 10x per tier:
|
||||||
|
- Tier 1: multiplier 1
|
||||||
|
- Tier 2: multiplier 10
|
||||||
|
- Tier 3: multiplier 100
|
||||||
|
- Tier 4: multiplier 1000
|
||||||
|
- Tier 5: multiplier 10000
|
||||||
|
|
||||||
|
This creates massive power jumps that may trivialize content when tiering up.
|
||||||
|
|
||||||
|
### 5.3 Special Effect Research Costs
|
||||||
|
|
||||||
|
Research skills for effects are expensive but effects may not be implemented:
|
||||||
|
- `researchSpecialEffects` costs 500 mana + 10 hours study
|
||||||
|
- Effects like `spellEcho10` are tracked but not applied
|
||||||
|
- Player invests resources for non-functional upgrades
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Critical Issues
|
||||||
|
|
||||||
|
### 6.1 computeDynamicRegen Not Used
|
||||||
|
|
||||||
|
**File:** `computed-stats.ts` lines 210-225
|
||||||
|
|
||||||
|
The function exists but only applies incursion penalty. It should call the more comprehensive `computeDynamicRegen` from `upgrade-effects.ts` that handles:
|
||||||
|
- Mana Cascade
|
||||||
|
- Mana Torrent
|
||||||
|
- Desperate Wells
|
||||||
|
- Steady Stream
|
||||||
|
|
||||||
|
### 6.2 No Consecutive Hit Tracking
|
||||||
|
|
||||||
|
`BATTLE_FURY` and `COMBO_MASTER` require tracking consecutive hits, but this state doesn't exist. Need:
|
||||||
|
```typescript
|
||||||
|
// In GameState
|
||||||
|
consecutiveHits: number;
|
||||||
|
totalHitsThisLoop: number;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Enchantment Special Effects Not Applied
|
||||||
|
|
||||||
|
The `specials` Set is populated but never checked in combat for enchantment-specific effects like:
|
||||||
|
- `lifesteal5`
|
||||||
|
- `spellEcho10`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Recommendations
|
||||||
|
|
||||||
|
### Priority 1 - Core Effects
|
||||||
|
1. Call `computeDynamicRegen()` from tick() instead of inline calculation
|
||||||
|
2. Call `computeDynamicDamage()` from combat section
|
||||||
|
3. Implement MANA_ECHO in gatherMana()
|
||||||
|
4. Implement EMERGENCY_RESERVE in startNewLoop()
|
||||||
|
|
||||||
|
### Priority 2 - Combat Effects
|
||||||
|
1. Add `consecutiveHits` to GameState
|
||||||
|
2. Implement BATTLE_FURY damage scaling
|
||||||
|
3. Implement COMBO_MASTER every 5th hit
|
||||||
|
4. Implement ADRENALINE_RUSH on kill
|
||||||
|
|
||||||
|
### Priority 3 - Study Effects
|
||||||
|
1. Implement MENTAL_CLARITY conditional speed
|
||||||
|
2. Implement STUDY_RUSH first hour bonus
|
||||||
|
3. Implement STUDY_REFUND on completion
|
||||||
|
4. Implement KNOWLEDGE_ECHO instant chance
|
||||||
|
|
||||||
|
### Priority 4 - Missing Systems
|
||||||
|
1. Implement or remove `scrollCrafting` skill
|
||||||
|
2. Implement or remove `fieldRepair` skill
|
||||||
|
3. Complete parallel study tick processing
|
||||||
|
4. Implement floor defense for ARMOR_PIERCE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Files Affected
|
||||||
|
|
||||||
|
| File | Changes Needed |
|
||||||
|
|------|----------------|
|
||||||
|
| `src/lib/game/store.ts` | Call dynamic effect functions, implement specials |
|
||||||
|
| `src/lib/game/computed-stats.ts` | Integrate with upgrade-effects dynamic functions |
|
||||||
|
| `src/lib/game/types.ts` | Add consecutiveHits to GameState |
|
||||||
|
| `src/lib/game/skill-evolution.ts` | Consider removing unimplementable upgrades |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of Audit Report**
|
||||||
23
Caddyfile
Executable file
23
Caddyfile
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
:81 {
|
||||||
|
@transform_port_query {
|
||||||
|
query XTransformPort=*
|
||||||
|
}
|
||||||
|
|
||||||
|
handle @transform_port_query {
|
||||||
|
reverse_proxy localhost:{query.XTransformPort} {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy localhost:3000 {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
Dockerfile
Executable file
66
Dockerfile
Executable file
@@ -0,0 +1,66 @@
|
|||||||
|
# Mana Loop - Next.js Game Docker Image
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
|
||||||
|
# Install bun
|
||||||
|
RUN npm install -g bun
|
||||||
|
|
||||||
|
# Copy package files first for better caching
|
||||||
|
COPY package.json bun.lockb* ./
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy the rest of the application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Set environment variables for build
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
RUN bunx prisma generate --schema=./prisma/schema.prisma
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Production image
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install openssl for Prisma
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV DATABASE_URL="file:./data/dev.db"
|
||||||
|
|
||||||
|
# Create data directory for SQLite
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Copy necessary files from builder
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3000 || exit 1
|
||||||
|
|
||||||
|
# Start the server (running as root)
|
||||||
|
CMD ["node", "server.js"]
|
||||||
510
GAME_SYSTEMS_ANALYSIS.md
Executable file
510
GAME_SYSTEMS_ANALYSIS.md
Executable file
@@ -0,0 +1,510 @@
|
|||||||
|
# Mana Loop - Game Systems Analysis Report
|
||||||
|
|
||||||
|
**Generated:** Task ID 24
|
||||||
|
**Purpose:** Comprehensive review of all game systems, their completeness, and "feel"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Mana Loop is an incremental/idle game with a time-loop mechanic, spellcasting combat, equipment enchanting, and attunement-based progression. The game has solid core mechanics but several systems feel incomplete or disconnected from the main gameplay loop.
|
||||||
|
|
||||||
|
**Overall Assessment:** ⚠️ **Needs Polish** - Core systems work but lack depth and integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System-by-System Analysis
|
||||||
|
|
||||||
|
### 1. 🔮 Core Mana System
|
||||||
|
|
||||||
|
**Status:** ✅ **Complete & Functional**
|
||||||
|
|
||||||
|
| Aspect | Rating | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Mana Regeneration | ⭐⭐⭐⭐⭐ | Well-implemented with upgrades affecting it |
|
||||||
|
| Mana Cap | ⭐⭐⭐⭐⭐ | Clear scaling through skills |
|
||||||
|
| Click Gathering | ⭐⭐⭐⭐ | Works but feels less important late-game |
|
||||||
|
| Mana Types | ⭐⭐⭐⭐ | Good variety (18 types) |
|
||||||
|
| Compound Mana | ⭐⭐⭐⭐ | Auto-unlocks when components available |
|
||||||
|
|
||||||
|
**What Works Well:**
|
||||||
|
- Clear progression: raw mana → elemental mana → compound mana
|
||||||
|
- Attunements provide passive conversion
|
||||||
|
- Incursion mechanic adds urgency late-loop
|
||||||
|
|
||||||
|
**What Feels Lacking:**
|
||||||
|
- Limited use cases for many mana types
|
||||||
|
- Compound mana types unlock automatically but feel disconnected from gameplay
|
||||||
|
- No meaningful choices in which mana to generate/prioritize
|
||||||
|
- Exotic elements (void, stellar, crystal) are very difficult to unlock
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add spells that specifically use compound/exotic elements
|
||||||
|
2. Allow players to choose which elements to generate from attunements
|
||||||
|
3. Add "mana conversion" buildings/upgrades that transform elements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. ⚔️ Combat/Spire System
|
||||||
|
|
||||||
|
**Status:** ⚠️ **Partially Complete**
|
||||||
|
|
||||||
|
| Aspect | Rating | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Floor Scaling | ⭐⭐⭐⭐⭐ | Good HP progression |
|
||||||
|
| Spell Casting | ⭐⭐⭐⭐ | Cast speed system works well |
|
||||||
|
| Elemental Weakness | ⭐⭐⭐⭐ | Opposing elements deal bonus damage |
|
||||||
|
| Guardian Fights | ⭐⭐⭐⭐ | Unique perks add flavor |
|
||||||
|
| Pact System | ⭐⭐⭐⭐⭐ | Excellent incentive to progress |
|
||||||
|
|
||||||
|
**What Works Well:**
|
||||||
|
- Guardian pacts provide permanent progression
|
||||||
|
- Each guardian has unique perks that feel impactful
|
||||||
|
- Descent mechanic prevents easy farming
|
||||||
|
- Barrier system on guardians adds tactical depth
|
||||||
|
|
||||||
|
**What Feels Lacking:**
|
||||||
|
- No active combat decisions - purely automatic
|
||||||
|
- Floor HP regeneration can feel frustrating without burst damage
|
||||||
|
- Limited spell selection (only from equipment)
|
||||||
|
- No enemy variety beyond floors/guardians
|
||||||
|
- Combo system exists in types but isn't actually used
|
||||||
|
|
||||||
|
**Critical Gap - Combo System:**
|
||||||
|
```typescript
|
||||||
|
// From types.ts - combo exists but isn't used
|
||||||
|
combo: {
|
||||||
|
count: number;
|
||||||
|
maxCombo: number;
|
||||||
|
multiplier: number;
|
||||||
|
elementChain: string[];
|
||||||
|
decayTimer: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The combo state is tracked but never affects gameplay. This is a dead system.
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Implement combo multiplier affecting damage
|
||||||
|
2. Add enemy types with different weaknesses
|
||||||
|
3. Allow manual spell selection mid-combat
|
||||||
|
4. Add tactical choices (focus fire, defensive casting, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. ✨ Enchanting System (Enchanter Attunement)
|
||||||
|
|
||||||
|
**Status:** ✅ **Complete & Well-Designed**
|
||||||
|
|
||||||
|
| Aspect | Rating | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Design Stage | ⭐⭐⭐⭐⭐ | Clear, intuitive UI |
|
||||||
|
| Prepare Stage | ⭐⭐⭐⭐ | Good time investment |
|
||||||
|
| Apply Stage | ⭐⭐⭐⭐ | Mana sink feels appropriate |
|
||||||
|
| Effect Variety | ⭐⭐⭐⭐ | Good selection of effects |
|
||||||
|
| Spell Granting | ⭐⭐⭐⭐⭐ | Primary way to get spells |
|
||||||
|
|
||||||
|
**What Works Well:**
|
||||||
|
- 3-stage process (Design → Prepare → Apply) feels meaningful
|
||||||
|
- Effect research system provides clear progression
|
||||||
|
- Spells come from equipment - creates itemization
|
||||||
|
- Disenchanting recovers some mana
|
||||||
|
|
||||||
|
**What Feels Lacking:**
|
||||||
|
- Effect capacity limits can feel arbitrary
|
||||||
|
- No way to preview enchantment before committing
|
||||||
|
- No rare/special enchantments
|
||||||
|
- Enchantment effects feel same-y (mostly +stats)
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add "rare" effect drops from guardians
|
||||||
|
2. Allow effect combining/stacking visually
|
||||||
|
3. Add visual flair to enchanted items
|
||||||
|
4. Create set bonuses for themed enchantments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 💜 Invoker/Pact System
|
||||||
|
|
||||||
|
**Status:** ⚠️ **Conceptually Complete, Implementation Lacking**
|
||||||
|
|
||||||
|
| Aspect | Rating | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Pact Signing | ⭐⭐⭐⭐ | Time investment, meaningful choice |
|
||||||
|
| Guardian Perks | ⭐⭐⭐⭐⭐ | Unique and impactful |
|
||||||
|
| Pact Multipliers | ⭐⭐⭐⭐ | Clear progression |
|
||||||
|
| Invoker Skills | ⭐⭐⭐ | Skills exist but category is sparse |
|
||||||
|
|
||||||
|
**What Works Well:**
|
||||||
|
- 10 unique guardians with distinct perks
|
||||||
|
- Pact multiplier system rewards guardian hunting
|
||||||
|
- Each pact feels like a real achievement
|
||||||
|
|
||||||
|
**What Feels Lacking:**
|
||||||
|
- **No Invocation category spells/skills defined**
|
||||||
|
- Invoker attunement has no primary mana type
|
||||||
|
- Limited invoker-specific progression
|
||||||
|
- Once you sign a pact, interaction ends
|
||||||
|
|
||||||
|
**Critical Gap - Invocation Skills:**
|
||||||
|
```typescript
|
||||||
|
// From SKILL_CATEGORIES
|
||||||
|
{ id: 'invocation', name: 'Invocation', icon: '💜', attunement: 'invoker' },
|
||||||
|
{ id: 'pact', name: 'Pact Mastery', icon: '🤝', attunement: 'invoker' },
|
||||||
|
```
|
||||||
|
|
||||||
|
Looking at SKILLS_DEF, there are **NO skills** in the 'invocation' or 'pact' categories! The attunement promises these categories but delivers nothing.
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add Invocation skills:
|
||||||
|
- Spirit Call (summon guardian echo)
|
||||||
|
- Elemental Channeling (boost pact element)
|
||||||
|
- Guardian's Boon (enhance perks)
|
||||||
|
2. Add Pact skills:
|
||||||
|
- Pact Binding (reduce signing time)
|
||||||
|
- Soul Link (gain mana from guardian defeats)
|
||||||
|
- Pact Synergy (combine perk effects)
|
||||||
|
3. Allow upgrading existing pacts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. ⚒️ Fabricator/Golemancy System
|
||||||
|
|
||||||
|
**Status:** ❌ **NOT IMPLEMENTED**
|
||||||
|
|
||||||
|
| Aspect | Rating | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Golem Defs | ❌ | GOLEM_DEFS does not exist |
|
||||||
|
| Golem Summoning | ❌ | No summoning logic |
|
||||||
|
| Golem Combat | ❌ | Golems don't fight |
|
||||||
|
| Crafting Skills | ⚠️ | Only in constants, not evolved |
|
||||||
|
|
||||||
|
**Critical Gap:**
|
||||||
|
```typescript
|
||||||
|
// From types.ts - these exist
|
||||||
|
export interface GolemDef { ... }
|
||||||
|
export interface ActiveGolem { ... }
|
||||||
|
|
||||||
|
// In GameState
|
||||||
|
activeGolems: ActiveGolem[];
|
||||||
|
unlockedGolemTypes: string[];
|
||||||
|
golemSummoningProgress: Record<string, number>;
|
||||||
|
```
|
||||||
|
|
||||||
|
But GOLEM_DEFS is referenced nowhere. The entire golemancy system is **stub code**.
|
||||||
|
|
||||||
|
**What Should Exist:**
|
||||||
|
1. GOLEM_DEFS with 5-10 golem types
|
||||||
|
2. Golem summoning logic (earth mana cost)
|
||||||
|
3. Golem combat integration (they fight alongside player)
|
||||||
|
4. Golem variants (earth + fire = magma golem)
|
||||||
|
5. Golem equipment/crystals for customization
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Implement basic earth golem first
|
||||||
|
2. Add golem as "pet" that attacks automatically
|
||||||
|
3. Golems should have limited duration (HP-based)
|
||||||
|
4. Crystals can enhance golem stats
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 📚 Skill System
|
||||||
|
|
||||||
|
**Status:** ⚠️ **Inconsistent**
|
||||||
|
|
||||||
|
| Aspect | Rating | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Skill Categories | ⭐⭐⭐⭐ | Good organization |
|
||||||
|
| Study System | ⭐⭐⭐⭐⭐ | Clear time investment |
|
||||||
|
| Evolution Paths | ⭐⭐⭐⭐⭐ | 5 tiers with choices |
|
||||||
|
| Upgrade Choices | ⭐⭐⭐⭐ | Meaningful decisions |
|
||||||
|
|
||||||
|
**What Works Well:**
|
||||||
|
- 4 upgrade choices per milestone (2 selected max)
|
||||||
|
- Tier progression multiplies effects
|
||||||
|
- Study time creates opportunity cost
|
||||||
|
|
||||||
|
**What Feels Lacking:**
|
||||||
|
- Many skills have no evolution path
|
||||||
|
- 'craft' category is legacy/unclear
|
||||||
|
- 'effectResearch' is scattered
|
||||||
|
- Some skills do nothing (scrollCrafting, fieldRepair)
|
||||||
|
|
||||||
|
**Dead Skills:**
|
||||||
|
```typescript
|
||||||
|
// In SKILLS_DEF but not implemented
|
||||||
|
scrollCrafting: { ... desc: "Create scrolls..." }, // No scroll system
|
||||||
|
fieldRepair: { ... desc: "+15% repair efficiency" }, // No repair system
|
||||||
|
```
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Remove or implement scrollCrafting/fieldRepair
|
||||||
|
2. Add evolution paths to all skills
|
||||||
|
3. Consolidate effectResearch into clearer tree
|
||||||
|
4. Add skill synergies (combining skills = bonus)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 🎯 Attunement System
|
||||||
|
|
||||||
|
**Status:** ⚠️ **Good Concept, Incomplete Execution**
|
||||||
|
|
||||||
|
| Aspect | Rating | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Concept | ⭐⭐⭐⭐⭐ | Class-like specialization |
|
||||||
|
| Enchanter | ⭐⭐⭐⭐⭐ | Fully implemented |
|
||||||
|
| Invoker | ⭐⭐ | Missing skills |
|
||||||
|
| Fabricator | ⭐ | Missing golemancy |
|
||||||
|
| Leveling | ⭐⭐⭐⭐ | Good XP scaling |
|
||||||
|
|
||||||
|
**What Works Well:**
|
||||||
|
- Enchanter attunement is complete and functional
|
||||||
|
- Attunement XP through gameplay feels natural
|
||||||
|
- Level-scaled conversion rates
|
||||||
|
|
||||||
|
**What Feels Lacking:**
|
||||||
|
- Invoker and Fabricator unlock conditions unclear
|
||||||
|
- Invoker has no Invocation/Pact skills
|
||||||
|
- Fabricator has no golemancy implementation
|
||||||
|
- Only 3 attunements, no late-game options
|
||||||
|
|
||||||
|
**Unlock Mystery:**
|
||||||
|
```typescript
|
||||||
|
// From attunements.ts
|
||||||
|
invoker: {
|
||||||
|
unlockCondition: 'Defeat your first guardian and choose the path of the Invoker',
|
||||||
|
// But no code checks for this condition
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add clear unlock triggers in code
|
||||||
|
2. Implement missing skill categories
|
||||||
|
3. Add 4th attunement for late-game (Void Walker?)
|
||||||
|
4. Create attunement-specific achievements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 🏆 Achievement System
|
||||||
|
|
||||||
|
**Status:** ✅ **Defined But Passive**
|
||||||
|
|
||||||
|
| Aspect | Rating | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Definitions | ⭐⭐⭐⭐ | Good variety |
|
||||||
|
| Progress Tracking | ⭐⭐⭐ | State exists |
|
||||||
|
| Rewards | ⭐⭐ | Mostly insight |
|
||||||
|
|
||||||
|
**What Works Well:**
|
||||||
|
- Categories organized (mana, combat, progression)
|
||||||
|
- Progress tracked in state
|
||||||
|
|
||||||
|
**What Feels Lacking:**
|
||||||
|
- Achievements don't unlock anything unique
|
||||||
|
- No visual display of achievements
|
||||||
|
- Rewards are passive (insight)
|
||||||
|
- No hidden/challenge achievements
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add achievement-locked cosmetics/titles
|
||||||
|
2. Create achievement showcase UI
|
||||||
|
3. Add challenge achievements (speedrun, no-upgrade, etc.)
|
||||||
|
4. Unlock effects through achievements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 📦 Equipment System
|
||||||
|
|
||||||
|
**Status:** ✅ **Complete**
|
||||||
|
|
||||||
|
| Aspect | Rating | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Equipment Types | ⭐⭐⭐⭐ | 8 slots, 40+ types |
|
||||||
|
| Capacity System | ⭐⭐⭐⭐⭐ | Clear limits |
|
||||||
|
| Rarity | ⭐⭐⭐ | Exists but cosmetic |
|
||||||
|
|
||||||
|
**What Works Well:**
|
||||||
|
- 8 equipment slots provide customization
|
||||||
|
- Capacity system limits power creep
|
||||||
|
- Equipment grants spells
|
||||||
|
|
||||||
|
**What Feels Lacking:**
|
||||||
|
- Equipment only comes from starting gear
|
||||||
|
- No way to craft equipment (except from blueprints)
|
||||||
|
- Rarity doesn't affect much
|
||||||
|
- No equipment drops from combat
|
||||||
|
|
||||||
|
**Critical Gap - Equipment Acquisition:**
|
||||||
|
Players start with:
|
||||||
|
- Basic Staff (Mana Bolt)
|
||||||
|
- Civilian Shirt
|
||||||
|
- Civilian Shoes
|
||||||
|
|
||||||
|
After that, the ONLY way to get equipment is:
|
||||||
|
1. Blueprint drops from floors (rare)
|
||||||
|
2. Craft from blueprint (expensive)
|
||||||
|
|
||||||
|
There's no consistent equipment progression!
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add equipment drops from floors
|
||||||
|
2. Create more crafting recipes
|
||||||
|
3. Add equipment merchant/shop
|
||||||
|
4. Allow equipment upgrading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. 🔁 Prestige/Loop System
|
||||||
|
|
||||||
|
**Status:** ✅ **Complete**
|
||||||
|
|
||||||
|
| Aspect | Rating | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Loop Reset | ⭐⭐⭐⭐⭐ | Clear, saves insight |
|
||||||
|
| Prestige Upgrades | ⭐⭐⭐⭐ | Good variety |
|
||||||
|
| Memory System | ⭐⭐⭐ | Keeps some progress |
|
||||||
|
| Victory Condition | ⭐⭐⭐⭐ | Defeat floor 100 guardian |
|
||||||
|
|
||||||
|
**What Works Well:**
|
||||||
|
- 30-day time limit creates urgency
|
||||||
|
- Insight economy for permanent upgrades
|
||||||
|
- Memory slots for keeping spells
|
||||||
|
- Clear victory condition
|
||||||
|
|
||||||
|
**What Feels Lacking:**
|
||||||
|
- No insight milestones/unlocks
|
||||||
|
- Memory system is shallow (just spell slots)
|
||||||
|
- No "loop challenges" or modifiers
|
||||||
|
- Limited replayability after first victory
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add loop modifiers (harder floors, better rewards)
|
||||||
|
2. Insight milestones unlock attunements
|
||||||
|
3. Loop-specific achievements
|
||||||
|
4. New Game+ mode with modifiers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. 🗓️ Time/Incursion System
|
||||||
|
|
||||||
|
**Status:** ✅ **Complete**
|
||||||
|
|
||||||
|
| Aspect | Rating | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Day/Hour Cycle | ⭐⭐⭐⭐⭐ | Clear progression |
|
||||||
|
| Incursion Mechanic | ⭐⭐⭐⭐ | Adds late-game pressure |
|
||||||
|
| Time Actions | ⭐⭐⭐ | Study, craft, prepare |
|
||||||
|
|
||||||
|
**What Works Well:**
|
||||||
|
- 30 days = one loop
|
||||||
|
- Incursion starts day 20, scales to 95% penalty
|
||||||
|
- Actions have clear time costs
|
||||||
|
|
||||||
|
**What Feels Lacking:**
|
||||||
|
- No time manipulation (beyond debug)
|
||||||
|
- No day/night effects on gameplay
|
||||||
|
- Incursion is purely negative, no strategy around it
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add time manipulation skills (slow incursion)
|
||||||
|
2. Night bonuses (different for guardians)
|
||||||
|
3. Incursion-specific rewards (void mana?)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Systems Summary
|
||||||
|
|
||||||
|
### High Priority (Break Promises)
|
||||||
|
|
||||||
|
| System | Promised By | Status |
|
||||||
|
|--------|-------------|--------|
|
||||||
|
| Golemancy | Fabricator attunement | ❌ Not implemented |
|
||||||
|
| Invocation Skills | Invoker attunement | ❌ No skills defined |
|
||||||
|
| Pact Skills | Invoker attunement | ❌ No skills defined |
|
||||||
|
| Combo System | ComboState in types | ❌ State exists, unused |
|
||||||
|
| Scroll Crafting | scrollCrafting skill | ❌ No scroll system |
|
||||||
|
|
||||||
|
### Medium Priority (Incomplete)
|
||||||
|
|
||||||
|
| System | Issue |
|
||||||
|
|--------|-------|
|
||||||
|
| Fabricator Unlocks | Unlock condition not coded |
|
||||||
|
| Invoker Unlocks | Unlock condition not coded |
|
||||||
|
| Equipment Progression | Only starting gear + rare blueprints |
|
||||||
|
| Evolution Paths | Not all skills have 5 tiers |
|
||||||
|
|
||||||
|
### Low Priority (Polish)
|
||||||
|
|
||||||
|
| System | Issue |
|
||||||
|
|--------|-------|
|
||||||
|
| Field Repair | Repair system doesn't exist |
|
||||||
|
| Guardian Variants | Not implemented |
|
||||||
|
| Achievement Rewards | Passive only |
|
||||||
|
| Enemy Variety | Only floors/guardians |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Feel" Analysis
|
||||||
|
|
||||||
|
### What Feels Good
|
||||||
|
|
||||||
|
1. **Guardian Pacts** - Defeating a guardian and signing a pact feels like a major achievement
|
||||||
|
2. **Enchanting Process** - 3-stage system feels involved and meaningful
|
||||||
|
3. **Cast Speed System** - Different spells feel different to use
|
||||||
|
4. **Skill Evolution** - Choosing upgrades at milestones gives agency
|
||||||
|
5. **Compound Mana** - Auto-unlocking elements through gameplay
|
||||||
|
|
||||||
|
### What Feels Bad
|
||||||
|
|
||||||
|
1. **Helplessness** - Combat is 100% automatic with no player input
|
||||||
|
2. **Dead Ends** - Attunements unlock with no skills to use
|
||||||
|
3. **Empty Promises** - Golemancy is mentioned everywhere but doesn't exist
|
||||||
|
4. **Grind Walls** - Exotic elements require absurd amounts of base elements
|
||||||
|
5. **Useless Skills** - scrollCrafting, fieldRepair do nothing
|
||||||
|
|
||||||
|
### What Feels Confusing
|
||||||
|
|
||||||
|
1. **Attunement Unlocks** - How do I get Invoker/Fabricator?
|
||||||
|
2. **Equipment Progression** - Where do I get better gear?
|
||||||
|
3. **Exotic Elements** - How do void/stellar/crystal work?
|
||||||
|
4. **Combo System** - UI mentions it but it does nothing
|
||||||
|
5. **Incursion** - Is there anything I can do about it?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Priorities
|
||||||
|
|
||||||
|
### Phase 1: Fix Broken Promises (1-2 weeks)
|
||||||
|
1. Implement basic golemancy (1 golem type, auto-attacks)
|
||||||
|
2. Add Invocation/Pact skill categories with 3-4 skills each
|
||||||
|
3. Add attunement unlock conditions in code
|
||||||
|
4. Remove or implement scrollCrafting/fieldRepair
|
||||||
|
|
||||||
|
### Phase 2: Fill Content Gaps (2-3 weeks)
|
||||||
|
1. Add equipment drops from floors
|
||||||
|
2. Implement combo system for damage bonuses
|
||||||
|
3. Add more spells using compound/exotic elements
|
||||||
|
4. Create evolution paths for all skills
|
||||||
|
|
||||||
|
### Phase 3: Polish & Depth (2-3 weeks)
|
||||||
|
1. Add tactical combat options
|
||||||
|
2. Create achievement showcase
|
||||||
|
3. Add loop modifiers/challenges
|
||||||
|
4. Implement equipment upgrading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Mana Loop has a strong foundation with unique mechanics (attunements, enchanting, pacts) that differentiate it from typical incremental games. However, several systems are incomplete or disconnected, creating confusion and limiting engagement.
|
||||||
|
|
||||||
|
**The biggest issues are:**
|
||||||
|
1. Golemancy is completely missing despite being promised
|
||||||
|
2. Invoker attunement has no skills
|
||||||
|
3. Combat has no player agency
|
||||||
|
4. Equipment progression is broken
|
||||||
|
|
||||||
|
**Focus on completing existing systems before adding new ones.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of Analysis Report*
|
||||||
377
README.md
Executable file
377
README.md
Executable file
@@ -0,0 +1,377 @@
|
|||||||
|
# Mana Loop
|
||||||
|
|
||||||
|
An incremental/idle game about climbing a magical spire, mastering skills, and uncovering the secrets of an ancient tower.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Mana Loop** is a browser-based incremental game where players gather mana, study skills and spells, climb the floors of a mysterious spire, and craft enchanted equipment. The game features a prestige system (Insight) that provides permanent progression bonuses across playthroughs.
|
||||||
|
|
||||||
|
### The Game Loop
|
||||||
|
|
||||||
|
1. **Gather Mana** - Click to collect mana or let it regenerate automatically
|
||||||
|
2. **Study Skills & Spells** - Spend mana to learn new abilities and unlock upgrades
|
||||||
|
3. **Climb the Spire** - Battle through floors, defeat guardians, and sign pacts for power
|
||||||
|
4. **Craft Equipment** - Enchant your gear with powerful effects
|
||||||
|
5. **Prestige** - Reset for Insight, gaining permanent bonuses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Mana Gathering & Management
|
||||||
|
- Click-based mana collection with automatic regeneration
|
||||||
|
- Elemental mana system with multiple elements
|
||||||
|
- Mana conversion between raw and elemental forms
|
||||||
|
- Meditation system for boosted regeneration
|
||||||
|
- Compound mana types created from base elements
|
||||||
|
- Exotic mana types for ultimate power
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔮 Mana Types
|
||||||
|
|
||||||
|
Mana is the core resource of Mana Loop. There are four categories of mana types:
|
||||||
|
|
||||||
|
### Base Mana Types (7)
|
||||||
|
| Element | Symbol | Color | Theme |
|
||||||
|
|---------|--------|-------|-------|
|
||||||
|
| Fire | 🔥 | #FF6B35 | Destruction, burn damage |
|
||||||
|
| Water | 💧 | #4ECDC4 | Flow, freeze effects |
|
||||||
|
| Air | 🌬️ | #00D4FF | Speed, wind damage |
|
||||||
|
| Earth | ⛰️ | #F4A261 | Stability, armor pierce |
|
||||||
|
| Light | ☀️ | #FFD700 | Radiance, holy damage |
|
||||||
|
| Dark | 🌑 | #9B59B6 | Shadows, void damage |
|
||||||
|
| Death | 💀 | #778CA3 | Decay, rot damage |
|
||||||
|
|
||||||
|
### Utility Mana Types (1)
|
||||||
|
| Element | Symbol | Color | Theme |
|
||||||
|
|---------|--------|-------|-------|
|
||||||
|
| Transference | 🔗 | #1ABC9C | Mana transfer, Enchanter attunement |
|
||||||
|
|
||||||
|
### Compound Mana Types (3)
|
||||||
|
Created by combining two base elements:
|
||||||
|
| Element | Recipe | Theme |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Metal | Fire + Earth | Armor piercing, forged weapons |
|
||||||
|
| Sand | Earth + Water | AOE damage, desert winds |
|
||||||
|
| Lightning | Fire + Air | Fast damage, armor pierce, chain effects |
|
||||||
|
|
||||||
|
### Exotic Mana Types (3)
|
||||||
|
Created from advanced recipes:
|
||||||
|
| Element | Recipe | Theme |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Crystal | Sand + Sand + Light | Prismatic, high damage |
|
||||||
|
| Stellar | Fire + Fire + Light | Cosmic, ultimate fire/light |
|
||||||
|
| Void | Dark + Dark + Death | Oblivion, ultimate dark/death |
|
||||||
|
|
||||||
|
### Mana Type Hierarchy
|
||||||
|
```
|
||||||
|
Base Elements (7)
|
||||||
|
↓
|
||||||
|
Compound (3) Utility (1)
|
||||||
|
↓
|
||||||
|
Exotic (3)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Skill Progression with Tier Evolution
|
||||||
|
- 20+ skills across multiple categories (mana, study, enchanting, golemancy)
|
||||||
|
- 5-tier evolution system for each skill
|
||||||
|
- Milestone upgrades at levels 5 and 10 for each tier
|
||||||
|
- Unique special effects unlocked through skill upgrades
|
||||||
|
|
||||||
|
### Equipment Crafting & Enchanting
|
||||||
|
- 3-stage enchantment process (Design → Prepare → Apply)
|
||||||
|
- Equipment capacity system limiting total enchantment power
|
||||||
|
- Enchantment effects including stat bonuses, multipliers, and spell grants
|
||||||
|
- Disenchanting to recover mana from unwanted enchantments (only in Prepare stage)
|
||||||
|
- Cannot re-enchant already enchanted gear
|
||||||
|
|
||||||
|
### Golemancy System
|
||||||
|
- Summon magical constructs to fight alongside you
|
||||||
|
- Golem slots unlock every 2 Fabricator levels (Level 2=1, Level 10=5)
|
||||||
|
- Base golems: Earth, Steel (metal), Crystal, Sand
|
||||||
|
- Advanced hybrid golems at Enchanter 5 + Fabricator 5: Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone
|
||||||
|
- Golems cost mana to summon and maintain
|
||||||
|
- Golemancy skills improve damage, speed, duration, and maintenance costs
|
||||||
|
|
||||||
|
### Combat System
|
||||||
|
- Cast speed-based spell casting
|
||||||
|
- Multi-spell support from equipped weapons
|
||||||
|
- Golem allies deal automatic damage each tick
|
||||||
|
- Elemental damage bonuses and effectiveness
|
||||||
|
- Floor guardians with unique boons and pacts
|
||||||
|
|
||||||
|
### Floor Navigation & Guardian Battles
|
||||||
|
- Procedurally generated spire floors
|
||||||
|
- Elemental floor themes affecting combat
|
||||||
|
- Guardian bosses with unique mechanics
|
||||||
|
- Pact system for permanent power boosts
|
||||||
|
|
||||||
|
### Prestige System (Insight)
|
||||||
|
- Reset progress for permanent bonuses
|
||||||
|
- Insight upgrades across multiple categories
|
||||||
|
- Signed pacts persist through prestige
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Technology | Purpose |
|
||||||
|
|------------|---------|
|
||||||
|
| **Next.js 16** | Full-stack framework with App Router |
|
||||||
|
| **TypeScript 5** | Type-safe development |
|
||||||
|
| **Tailwind CSS 4** | Utility-first styling |
|
||||||
|
| **shadcn/ui** | Reusable UI components |
|
||||||
|
| **Zustand** | Client state management with persistence |
|
||||||
|
| **Prisma ORM** | Database abstraction (SQLite) |
|
||||||
|
| **Bun** | JavaScript runtime and package manager |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** 18+ or **Bun** runtime
|
||||||
|
- **npm**, **yarn**, or **bun** package manager
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone git@gitea.tailf367e3.ts.net:Anexim/Mana-Loop.git
|
||||||
|
cd Mana-Loop
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
# or
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Set up the database
|
||||||
|
bun run db:push
|
||||||
|
# or
|
||||||
|
npm run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the development server
|
||||||
|
bun run dev
|
||||||
|
# or
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The game will be available at `http://localhost:3000`.
|
||||||
|
|
||||||
|
### Other Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run linting
|
||||||
|
bun run lint
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
bun run start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── page.tsx # Main game UI (single-page application)
|
||||||
|
│ ├── layout.tsx # Root layout with providers
|
||||||
|
│ └── api/ # API routes
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn/ui components
|
||||||
|
│ └── game/ # Game-specific components
|
||||||
|
│ ├── tabs/ # Tab-based UI components
|
||||||
|
│ │ ├── CraftingTab.tsx
|
||||||
|
│ │ ├── GolemancyTab.tsx
|
||||||
|
│ │ ├── LabTab.tsx
|
||||||
|
│ │ ├── SpellsTab.tsx
|
||||||
|
│ │ ├── SpireTab.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── ManaDisplay.tsx
|
||||||
|
│ ├── TimeDisplay.tsx
|
||||||
|
│ ├── ActionButtons.tsx
|
||||||
|
│ └── ...
|
||||||
|
└── lib/
|
||||||
|
├── game/
|
||||||
|
│ ├── store.ts # Zustand store (state + actions)
|
||||||
|
│ ├── effects.ts # Unified effect computation
|
||||||
|
│ ├── upgrade-effects.ts # Skill upgrade definitions
|
||||||
|
│ ├── skill-evolution.ts # Tier progression paths
|
||||||
|
│ ├── constants.ts # Game data definitions
|
||||||
|
│ ├── computed-stats.ts # Stat calculation functions
|
||||||
|
│ ├── crafting-slice.ts # Equipment/enchantment actions
|
||||||
|
│ ├── navigation-slice.ts # Floor navigation actions
|
||||||
|
│ ├── study-slice.ts # Study system actions
|
||||||
|
│ ├── types.ts # TypeScript interfaces
|
||||||
|
│ ├── formatting.ts # Display formatters
|
||||||
|
│ ├── utils.ts # Utility functions
|
||||||
|
│ └── data/
|
||||||
|
│ ├── equipment.ts # Equipment definitions
|
||||||
|
│ ├── enchantment-effects.ts # Enchantment catalog
|
||||||
|
│ ├── golems.ts # Golem definitions
|
||||||
|
│ ├── crafting-recipes.ts # Crafting recipes
|
||||||
|
│ ├── achievements.ts # Achievement definitions
|
||||||
|
│ └── loot-drops.ts # Loot drop tables
|
||||||
|
└── utils.ts # General utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
For detailed architecture documentation, see [AGENTS.md](./AGENTS.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Game Systems Overview
|
||||||
|
|
||||||
|
### Mana System
|
||||||
|
The core resource of the game. Mana is gathered manually or automatically and used for studying skills, casting spells, and crafting.
|
||||||
|
|
||||||
|
**Key Files:**
|
||||||
|
- `store.ts` - Mana state and actions
|
||||||
|
- `computed-stats.ts` - Mana calculations
|
||||||
|
|
||||||
|
### Skill System
|
||||||
|
Skills provide passive bonuses and unlock new abilities. Each skill can evolve through 5 tiers with milestone upgrades.
|
||||||
|
|
||||||
|
**Key Files:**
|
||||||
|
- `constants.ts` - Skill definitions (`SKILLS_DEF`)
|
||||||
|
- `skill-evolution.ts` - Evolution paths and upgrades
|
||||||
|
- `upgrade-effects.ts` - Effect computation
|
||||||
|
|
||||||
|
### Combat System
|
||||||
|
Combat uses a cast-speed system where each spell has a unique cast rate. Damage is calculated with skill bonuses, elemental modifiers, and special effects.
|
||||||
|
|
||||||
|
**Key Files:**
|
||||||
|
- `store.ts` - Combat tick logic
|
||||||
|
- `constants.ts` - Spell definitions (`SPELLS_DEF`)
|
||||||
|
- `effects.ts` - Damage calculations
|
||||||
|
|
||||||
|
### Enchanting System
|
||||||
|
A 3-stage enchantment system for equipment. Design effects, prepare equipment, and apply enchantments within capacity limits.
|
||||||
|
|
||||||
|
**Key Rules:**
|
||||||
|
- Design: Choose effects for your equipment type
|
||||||
|
- Prepare: Prepare equipment for enchanting (ONLY way to disenchant existing enchantments)
|
||||||
|
- Apply: Apply designed enchantments (cannot re-enchant already enchanted gear)
|
||||||
|
|
||||||
|
**Key Files:**
|
||||||
|
- `crafting-slice.ts` - Crafting actions
|
||||||
|
- `data/equipment.ts` - Equipment types
|
||||||
|
- `data/enchantment-effects.ts` - Available effects
|
||||||
|
|
||||||
|
### Golemancy System
|
||||||
|
Summon magical constructs to fight alongside you. Requires Fabricator attunement.
|
||||||
|
|
||||||
|
**Golem Slots:**
|
||||||
|
- Fabricator Level 2: 1 slot
|
||||||
|
- Fabricator Level 4: 2 slots
|
||||||
|
- Fabricator Level 6: 3 slots
|
||||||
|
- Fabricator Level 8: 4 slots
|
||||||
|
- Fabricator Level 10: 5 slots
|
||||||
|
|
||||||
|
**Golem Types:**
|
||||||
|
- Base: Earth (available at Fabricator 2)
|
||||||
|
- Element Unlocks: Steel (metal), Crystal, Sand
|
||||||
|
- Hybrids (Enchanter 5 + Fabricator 5): Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone
|
||||||
|
|
||||||
|
**Key Files:**
|
||||||
|
- `data/golems.ts` - Golem definitions and unlock conditions
|
||||||
|
- `store.ts` - Golemancy actions and state
|
||||||
|
|
||||||
|
### Prestige System
|
||||||
|
Reset progress for Insight, which provides permanent bonuses. Signed pacts persist through prestige.
|
||||||
|
|
||||||
|
**Key Files:**
|
||||||
|
- `store.ts` - Prestige logic
|
||||||
|
- `constants.ts` - Insight upgrades
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
We welcome contributions! Please follow these guidelines:
|
||||||
|
|
||||||
|
### Development Workflow
|
||||||
|
|
||||||
|
1. **Pull the latest changes** before starting work
|
||||||
|
2. **Create a feature branch** for your changes
|
||||||
|
3. **Follow existing patterns** in the codebase
|
||||||
|
4. **Run linting** before committing (`bun run lint`)
|
||||||
|
5. **Test your changes** thoroughly
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- TypeScript throughout with strict typing
|
||||||
|
- Use existing shadcn/ui components over custom implementations
|
||||||
|
- Follow the slice pattern for store actions
|
||||||
|
- Keep components focused and extract to separate files when >50 lines
|
||||||
|
|
||||||
|
### Adding New Features
|
||||||
|
|
||||||
|
For detailed patterns on adding new effects, skills, spells, or systems, see [AGENTS.md](./AGENTS.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Banned Content
|
||||||
|
|
||||||
|
The following content has been removed from the game and should not be re-added:
|
||||||
|
|
||||||
|
### Banned Mechanics
|
||||||
|
- **Lifesteal** - Player cannot heal from dealing damage
|
||||||
|
- **Healing** - Player cannot heal themselves (floors take damage, not player)
|
||||||
|
|
||||||
|
### Banned Mana Types
|
||||||
|
- **Life** - Removed (healing theme conflicts with core design)
|
||||||
|
- **Blood** - Removed (life derivative)
|
||||||
|
- **Wood** - Removed (life derivative)
|
||||||
|
- **Mental** - Removed
|
||||||
|
- **Force** - Removed
|
||||||
|
|
||||||
|
### Banned Systems
|
||||||
|
- **Familiar System** - Removed in favor of Golemancy and Pact systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
|
|
||||||
|
```
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Mana Loop
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Built with love using modern web technologies. Special thanks to the open-source community for the amazing tools that make this project possible.
|
||||||
118
REFACTORING_PLAN.md
Executable file
118
REFACTORING_PLAN.md
Executable file
@@ -0,0 +1,118 @@
|
|||||||
|
# Mana Loop - Component Refactoring Plan
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
The main `page.tsx` file is ~2000+ lines and contains all game logic and rendering in a single component. This makes it:
|
||||||
|
- Hard to maintain
|
||||||
|
- Hard to test individual features
|
||||||
|
- Prone to performance issues (entire component re-renders on any state change)
|
||||||
|
- Difficult for multiple developers to work on
|
||||||
|
|
||||||
|
## Proposed Component Structure
|
||||||
|
|
||||||
|
### Directory: `/src/components/game/`
|
||||||
|
|
||||||
|
#### Layout Components
|
||||||
|
```
|
||||||
|
GameLayout.tsx - Main layout wrapper with header/footer
|
||||||
|
Calendar.tsx - Day calendar display in header
|
||||||
|
ResourceBar.tsx - Day/time/insight display in header
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sidebar Components
|
||||||
|
```
|
||||||
|
ManaSidebar.tsx - Left sidebar container
|
||||||
|
ManaDisplay.tsx - Current mana, max mana, regen display
|
||||||
|
GatherButton.tsx - Click-to-gather button with hold functionality
|
||||||
|
ActionButtons.tsx - Meditate/Climb/Study/Convert buttons
|
||||||
|
FloorStatus.tsx - Current floor display with HP bar
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tab Content Components
|
||||||
|
```
|
||||||
|
SpireTab.tsx - Main spire/combat tab
|
||||||
|
├── CurrentFloor.tsx - Floor info, guardian, HP bar
|
||||||
|
├── ActiveSpell.tsx - Active spell details and cast progress
|
||||||
|
├── StudyProgress.tsx - Current study progress display
|
||||||
|
├── KnownSpells.tsx - Grid of learned spells
|
||||||
|
└── ActivityLog.tsx - Game event log
|
||||||
|
|
||||||
|
SpellsTab.tsx - Spell learning tab
|
||||||
|
├── SpellTier.tsx - Spells grouped by tier
|
||||||
|
└── SpellCard.tsx - Individual spell card
|
||||||
|
|
||||||
|
LabTab.tsx - Elemental mana lab tab
|
||||||
|
├── ElementalMana.tsx - Grid of elemental mana
|
||||||
|
├── ElementConversion.tsx - Convert raw to element
|
||||||
|
├── UnlockElements.tsx - Unlock new elements
|
||||||
|
└── CompositeCrafting.tsx - Craft composite elements
|
||||||
|
|
||||||
|
SkillsTab.tsx - Skill learning tab
|
||||||
|
├── SkillCategory.tsx - Skills grouped by category
|
||||||
|
└── SkillCard.tsx - Individual skill card
|
||||||
|
|
||||||
|
GrimoireTab.tsx - Prestige upgrades tab
|
||||||
|
└── PrestigeCard.tsx - Individual prestige upgrade
|
||||||
|
|
||||||
|
StatsTab.tsx - Detailed stats breakdown
|
||||||
|
├── ManaStats.tsx - Mana-related stats
|
||||||
|
├── CombatStats.tsx - Combat-related stats
|
||||||
|
├── StudyStats.tsx - Study-related stats
|
||||||
|
└── ActiveUpgrades.tsx - List of active skill upgrades
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Shared Components
|
||||||
|
```
|
||||||
|
SpellCost.tsx - Formatted spell cost display
|
||||||
|
DamageBreakdown.tsx - Damage calculation breakdown
|
||||||
|
BuffIndicator.tsx - Active buff/debuff display
|
||||||
|
UpgradeDialog.tsx - Skill upgrade selection dialog
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Extract Simple Components (Low Risk)
|
||||||
|
- [ ] Calendar.tsx
|
||||||
|
- [ ] ActivityLog.tsx
|
||||||
|
- [ ] SpellCost.tsx
|
||||||
|
- [ ] KnownSpells.tsx
|
||||||
|
|
||||||
|
### Phase 2: Extract Tab Components (Medium Risk)
|
||||||
|
- [ ] SpireTab.tsx
|
||||||
|
- [ ] SpellsTab.tsx
|
||||||
|
- [ ] LabTab.tsx
|
||||||
|
- [ ] SkillsTab.tsx
|
||||||
|
- [ ] GrimoireTab.tsx
|
||||||
|
- [ ] StatsTab.tsx
|
||||||
|
|
||||||
|
### Phase 3: Extract Sidebar Components (Medium Risk)
|
||||||
|
- [ ] ManaDisplay.tsx
|
||||||
|
- [ ] GatherButton.tsx
|
||||||
|
- [ ] ActionButtons.tsx
|
||||||
|
- [ ] FloorStatus.tsx
|
||||||
|
|
||||||
|
### Phase 4: Create Shared Hooks
|
||||||
|
- [ ] useGameState.ts - Custom hook for game state access
|
||||||
|
- [ ] useComputedStats.ts - Memoized computed stats
|
||||||
|
- [ ] useSpellCast.ts - Spell casting logic
|
||||||
|
- [ ] useStudy.ts - Skill/spell study logic
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
1. **Maintainability**: Each component has a single responsibility
|
||||||
|
2. **Performance**: React can skip re-rendering unchanged components
|
||||||
|
3. **Testability**: Individual components can be tested in isolation
|
||||||
|
4. **Collaboration**: Multiple developers can work on different components
|
||||||
|
5. **Code Reuse**: Shared components can be used across tabs
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
1. Create new component file
|
||||||
|
2. Move relevant code from page.tsx
|
||||||
|
3. Pass required props or use store directly
|
||||||
|
4. Add TypeScript types
|
||||||
|
5. Test functionality
|
||||||
|
6. Remove code from page.tsx
|
||||||
|
7. Repeat for each component
|
||||||
|
|
||||||
|
## Estimated Lines of Code Reduction
|
||||||
|
- Current page.tsx: ~2000 lines
|
||||||
|
- After refactoring: ~200-300 lines (main layout only)
|
||||||
|
- Total component files: ~30-40 files averaging 50-100 lines each
|
||||||
21
components.json
Executable file
21
components.json
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
124
crafting-implementation-summary.md
Executable file
124
crafting-implementation-summary.md
Executable file
@@ -0,0 +1,124 @@
|
|||||||
|
# Crafting & Equipment System Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Replaced the combat skills system with a comprehensive equipment and enchantment system. Players now gain combat abilities through enchanted equipment rather than skills.
|
||||||
|
|
||||||
|
## Completed Tasks
|
||||||
|
|
||||||
|
### 1. Equipment System (✅ Complete)
|
||||||
|
- **File**: `/src/lib/game/data/equipment.ts`
|
||||||
|
- 8 equipment slots: mainHand, offHand, head, body, hands, feet, accessory1, accessory2
|
||||||
|
- 20+ equipment types across categories: caster, shield, catalyst, head, body, hands, feet, accessory
|
||||||
|
- Capacity system: Each equipment has base capacity for enchantments
|
||||||
|
- Starting equipment: Basic Staff (with Mana Bolt), Civilian Shirt/Gloves/Shoes
|
||||||
|
|
||||||
|
### 2. Enchantment Effects Catalogue (✅ Complete)
|
||||||
|
- **File**: `/src/lib/game/data/enchantment-effects.ts`
|
||||||
|
- 100+ enchantment effects across 7 categories:
|
||||||
|
- **Spell** (40+): Grant ability to cast specific spells
|
||||||
|
- **Mana** (20+): Capacity, regen, efficiency bonuses
|
||||||
|
- **Combat** (15+): Damage, crit, attack speed
|
||||||
|
- **Elemental** (10+): Elemental damage bonuses
|
||||||
|
- **Defense** (4): Damage reduction, mana shields
|
||||||
|
- **Utility** (8): Study speed, meditation, insight
|
||||||
|
- **Special** (12): Unique effects like echo, lifesteal, executioner
|
||||||
|
|
||||||
|
### 3. Crafting Store Slice (✅ Complete)
|
||||||
|
- **File**: `/src/lib/game/store/craftingSlice.ts`
|
||||||
|
- Equipment instance management
|
||||||
|
- Enchantment design workflow
|
||||||
|
- Preparation progress tracking
|
||||||
|
- Application progress with mana consumption
|
||||||
|
- Helper functions for computing equipment effects
|
||||||
|
|
||||||
|
### 4. CraftingTab UI Component (✅ Complete)
|
||||||
|
- **File**: `/src/components/game/tabs/CraftingTab.tsx`
|
||||||
|
- 4 sub-tabs: Equipment, Design, Enchant, Craft
|
||||||
|
- Equipment slot visualization with rarity colors
|
||||||
|
- Effect catalogue with capacity preview
|
||||||
|
- Design creation workflow
|
||||||
|
|
||||||
|
### 5. Type Definitions (✅ Complete)
|
||||||
|
- **File**: `/src/lib/game/types.ts`
|
||||||
|
- EquipmentInstance, AppliedEnchantment, EnchantmentDesign interfaces
|
||||||
|
- Crafting progress states (DesignProgress, PreparationProgress, ApplicationProgress)
|
||||||
|
|
||||||
|
### 6. Skill Evolution Cleanup (✅ Complete)
|
||||||
|
- **File**: `/src/lib/game/skill-evolution.ts`
|
||||||
|
- Removed combatTrain evolution path (orphaned - skill not in SKILLS_DEF)
|
||||||
|
- Removed COMBAT_TRAIN upgrade definitions
|
||||||
|
|
||||||
|
### 7. Bug Fixes (✅ Complete)
|
||||||
|
- Fixed CraftingTab icon imports (Ring → Circle, HandMetal → Hand)
|
||||||
|
|
||||||
|
## Remaining Tasks (Future Work)
|
||||||
|
|
||||||
|
### 1. Equipment-Granted Spells in Combat (Medium Priority)
|
||||||
|
- The `equipmentSpellStates` array exists but isn't fully utilized
|
||||||
|
- Combat tick should check for equipment-granted spells
|
||||||
|
- Multi-spell casting from different equipment pieces
|
||||||
|
- Individual spell cooldown tracking
|
||||||
|
|
||||||
|
### 2. SpellsTab Integration (Low Priority)
|
||||||
|
- Add section showing equipment-granted spells
|
||||||
|
- Indicate which equipment piece grants each spell
|
||||||
|
- Distinguish between learned vs equipment-granted spells
|
||||||
|
|
||||||
|
### 3. Evolution Paths for Crafting Skills (Low Priority)
|
||||||
|
- Add evolution paths for existing crafting skills:
|
||||||
|
- `effCrafting` → Efficient Enchanter
|
||||||
|
- `durableConstruct` → Master Artisan
|
||||||
|
- `fieldRepair` → Field Engineer
|
||||||
|
|
||||||
|
## System Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Equipment System Flow:
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Equipment Instance │
|
||||||
|
│ - typeId: Reference to EquipmentTypeDef │
|
||||||
|
│ - enchantments: AppliedEnchantment[] │
|
||||||
|
│ - usedCapacity / totalCapacity │
|
||||||
|
│ - rarity: Based on total capacity used │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Enchantment Design │
|
||||||
|
│ 1. Select effects from catalogue │
|
||||||
|
│ 2. Check capacity constraints │
|
||||||
|
│ 3. Pay design time cost │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Preparation │
|
||||||
|
│ - Clear existing enchantments │
|
||||||
|
│ - Pay mana cost (equipment capacity × 5) │
|
||||||
|
│ - Wait preparation time (capacity / 5 hours) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Application │
|
||||||
|
│ - Continuous mana drain during application │
|
||||||
|
│ - Time based on total effect capacity │
|
||||||
|
│ - Can be paused if mana runs low │
|
||||||
|
│ - On completion: Apply enchantments to equipment │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **Capacity System**: Equipment has capacity that limits total enchantment power. This creates meaningful choices about which effects to apply.
|
||||||
|
|
||||||
|
2. **Effect Categories**: Each equipment type only accepts certain effect categories, creating specialization:
|
||||||
|
- Staves: All spell types + mana + combat + elemental
|
||||||
|
- Shields: Defense + utility only
|
||||||
|
- Accessories: Small bonuses, no spells
|
||||||
|
|
||||||
|
3. **Stacking Effects**: Many effects can be stacked multiple times for increased power, with increasing capacity costs.
|
||||||
|
|
||||||
|
4. **Rarity from Power**: Rarity is automatically calculated from total capacity used, making powerful enchantments visually distinctive.
|
||||||
|
|
||||||
|
5. **Time-Gated Application**: Enchantment application takes real time with continuous mana drain, preventing instant power spikes.
|
||||||
BIN
db/custom.db
Executable file
BIN
db/custom.db
Executable file
Binary file not shown.
31
dev-log.notes
Executable file
31
dev-log.notes
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
## Crafting and Equipment System - Implementation Notes
|
||||||
|
|
||||||
|
### Date: $(date)
|
||||||
|
|
||||||
|
### Completed Tasks:
|
||||||
|
1. ✅ Fixed CraftingTab icon imports (Ring → Circle, HandMetal → Hand)
|
||||||
|
2. ✅ Removed combat skill evolution paths from skill-evolution.ts:
|
||||||
|
- Removed COMBAT_TRAIN_TIER1_UPGRADES_L5 upgrade definitions
|
||||||
|
- Removed COMBAT_TRAIN_TIER1_UPGRADES_L10 upgrade definitions
|
||||||
|
- Removed combatTrain evolution path (all 5 tiers)
|
||||||
|
|
||||||
|
### System Architecture:
|
||||||
|
- **Equipment System**: 8 slots (mainHand, offHand, head, body, hands, feet, accessory1, accessory2)
|
||||||
|
- **Equipment Types**: Different categories (caster, shield, catalyst, head, body, hands, feet, accessory)
|
||||||
|
- **Capacity System**: Each equipment has base capacity, effects cost capacity
|
||||||
|
- **Enchantment Effects**: 7 categories (spell, mana, combat, elemental, defense, utility, special)
|
||||||
|
- **Starting Equipment**: Basic Staff (with Mana Bolt), Civilian Shirt/Gloves/Shoes
|
||||||
|
|
||||||
|
### Key Files:
|
||||||
|
- `/src/lib/game/data/equipment.ts` - Equipment types and capacity system
|
||||||
|
- `/src/lib/game/data/enchantment-effects.ts` - Enchantment effect catalogue
|
||||||
|
- `/src/lib/game/store/craftingSlice.ts` - Crafting store slice
|
||||||
|
- `/src/components/game/tabs/CraftingTab.tsx` - Crafting UI component
|
||||||
|
- `/src/lib/game/skill-evolution.ts` - Skill evolution paths (combat skills removed)
|
||||||
|
|
||||||
|
### Remaining Tasks:
|
||||||
|
- Update SpellsTab to work with equipment-granted spells
|
||||||
|
- Add evolution paths for crafting skills (optional)
|
||||||
|
- Add tests for new crafting system
|
||||||
|
|
||||||
15
docker-compose.yml
Executable file
15
docker-compose.yml
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
mana-loop:
|
||||||
|
image: gitea.tailf367e3.ts.net/anexim/mana-loop:latest
|
||||||
|
container_name: mana-loop
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
526
docs/skills.md
Executable file
526
docs/skills.md
Executable file
@@ -0,0 +1,526 @@
|
|||||||
|
# Mana Loop - Complete Skill System Documentation
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Core Mechanics](#core-mechanics)
|
||||||
|
3. [Skill Categories](#skill-categories)
|
||||||
|
4. [All Skills Reference](#all-skills-reference)
|
||||||
|
5. [Upgrade Trees](#upgrade-trees)
|
||||||
|
6. [Tier System](#tier-system)
|
||||||
|
7. [Banned Content](#banned-content)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The skill system in Mana Loop provides deep character customization through a branching upgrade tree system. Skills are organized by attunement, with each attunement granting access to specific skill categories.
|
||||||
|
|
||||||
|
### Skill Level Types
|
||||||
|
|
||||||
|
| Max Level | Description | Example Skills |
|
||||||
|
|-----------|-------------|----------------|
|
||||||
|
| 10 | Standard skills with full upgrade trees | Mana Well, Mana Flow, Enchanting |
|
||||||
|
| 5 | Specialized skills with limited upgrades | Efficient Enchant, Golem Mastery |
|
||||||
|
| 3 | Focused skills with no upgrades | Knowledge Retention, Golem Longevity |
|
||||||
|
| 1 | Effect research skills (unlock only) | All research skills |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Mechanics
|
||||||
|
|
||||||
|
### Study System
|
||||||
|
|
||||||
|
Leveling skills requires:
|
||||||
|
1. **Mana cost** - Paid upfront to begin study
|
||||||
|
2. **Study time** - Hours required to complete
|
||||||
|
3. **Active studying** - Must be in "study" action mode
|
||||||
|
|
||||||
|
#### Study Cost Formula
|
||||||
|
```
|
||||||
|
cost = baseCost × (currentLevel + 1) × tier × costMultiplier
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Study Time Formula
|
||||||
|
```
|
||||||
|
time = baseStudyTime × tier / studySpeedMultiplier
|
||||||
|
```
|
||||||
|
|
||||||
|
### Milestone Upgrades
|
||||||
|
|
||||||
|
At **levels 5 and 10**, you choose **1 upgrade** from an upgrade tree:
|
||||||
|
- Each skill has its own unique upgrade tree
|
||||||
|
- Trees have branching paths with prerequisites
|
||||||
|
- Choices are permanent for that tier
|
||||||
|
- Upgrades persist when tiering up
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skill Categories
|
||||||
|
|
||||||
|
### Core Categories (No Attunement Required)
|
||||||
|
|
||||||
|
| Category | Icon | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| Mana | 💧 | Mana pool and regeneration |
|
||||||
|
| Study | 📚 | Learning speed and efficiency |
|
||||||
|
| Research | 🔮 | Permanent bonuses |
|
||||||
|
| Ascension | ⭐ | Loop-persisting benefits |
|
||||||
|
|
||||||
|
### Attunement Categories
|
||||||
|
|
||||||
|
| Category | Icon | Attunement | Description |
|
||||||
|
|----------|------|------------|-------------|
|
||||||
|
| Enchanting | ✨ | Enchanter | Enchantment design and efficiency |
|
||||||
|
| Effect Research | 🔬 | Enchanter | Unlock spell enchantments |
|
||||||
|
| Invocation | 💜 | Invoker | Pact-based abilities |
|
||||||
|
| Pact Mastery | 🤝 | Invoker | Guardian pact bonuses |
|
||||||
|
| Fabrication | ⚒️ | Fabricator | Crafting and construction |
|
||||||
|
| Golemancy | 🗿 | Fabricator | Golem summoning and control |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## All Skills Reference
|
||||||
|
|
||||||
|
### Mana Skills (Core)
|
||||||
|
|
||||||
|
| Skill | Max | Effect | Base Cost | Study Time |
|
||||||
|
|-------|-----|--------|-----------|------------|
|
||||||
|
| Mana Well | 10 | +100 max mana/level | 100 | 4h |
|
||||||
|
| Mana Flow | 10 | +1 regen/hour/level | 150 | 5h |
|
||||||
|
| Elemental Attunement | 10 | +50 element cap/level | 200 | 4h |
|
||||||
|
| Mana Overflow | 5 | +25% click mana/level | 400 | 6h |
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Mana Overflow: Mana Well 3
|
||||||
|
|
||||||
|
### Study Skills (Core)
|
||||||
|
|
||||||
|
| Skill | Max | Effect | Base Cost | Study Time |
|
||||||
|
|-------|-----|--------|-----------|------------|
|
||||||
|
| Quick Learner | 10 | +10% study speed/level | 250 | 4h |
|
||||||
|
| Focused Mind | 10 | -5% study cost/level | 300 | 5h |
|
||||||
|
| Meditation Focus | 1 | Up to 2.5x regen after 4hrs | 400 | 6h |
|
||||||
|
| Knowledge Retention | 3 | +20% progress saved on cancel/level | 350 | 5h |
|
||||||
|
|
||||||
|
### Research Skills (Core)
|
||||||
|
|
||||||
|
| Skill | Max | Effect | Base Cost | Study Time |
|
||||||
|
|-------|-----|--------|-----------|------------|
|
||||||
|
| Mana Tap | 1 | +1 mana/click | 300 | 12h |
|
||||||
|
| Mana Surge | 1 | +3 mana/click | 800 | 36h |
|
||||||
|
| Mana Spring | 1 | +2 mana regen | 600 | 24h |
|
||||||
|
| Deep Trance | 1 | 6hr meditation = 3x regen | 900 | 48h |
|
||||||
|
| Void Meditation | 1 | 8hr meditation = 5x regen | 1500 | 72h |
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Mana Surge: Mana Tap 1
|
||||||
|
- Deep Trance: Meditation 1
|
||||||
|
- Void Meditation: Deep Trance 1
|
||||||
|
|
||||||
|
### Ascension Skills (Any Attunement)
|
||||||
|
|
||||||
|
| Skill | Max | Effect | Base Cost | Study Time | Attunement Req |
|
||||||
|
|-------|-----|--------|-----------|------------|----------------|
|
||||||
|
| Insight Harvest | 5 | +10% insight/level | 1000 | 20h | Any level 5+ |
|
||||||
|
| Temporal Memory | 3 | Keep 1 spell/level across loops | 2000 | 36h | Any level 5+ |
|
||||||
|
| Guardian Bane | 3 | +20% dmg vs guardians/level | 1500 | 30h | Invoker 1 |
|
||||||
|
|
||||||
|
### Enchanting Skills (Enchanter)
|
||||||
|
|
||||||
|
| Skill | Max | Effect | Base Cost | Study Time | Attunement Req |
|
||||||
|
|-------|-----|--------|-----------|------------|----------------|
|
||||||
|
| Enchanting | 10 | Unlocks enchantment design | 200 | 5h | Enchanter 1 |
|
||||||
|
| Efficient Enchant | 5 | -5% capacity cost/level | 350 | 6h | Enchanter 2 |
|
||||||
|
| Disenchanting | 3 | +20% mana recovery/level | 400 | 6h | Enchanter 1 |
|
||||||
|
| Enchant Speed | 5 | -10% enchant time/level | 300 | 4h | Enchanter 1 |
|
||||||
|
| Scroll Crafting | 3 | Store enchantment designs | 500 | 8h | Enchanter 3 |
|
||||||
|
| Essence Refining | 5 | +10% effect power/level | 450 | 7h | Enchanter 2 |
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Efficient Enchant: Enchanting 3
|
||||||
|
- Disenchanting: Enchanting 2
|
||||||
|
- Enchant Speed: Enchanting 2
|
||||||
|
- Scroll Crafting: Enchanting 5
|
||||||
|
- Essence Refining: Enchanting 4
|
||||||
|
|
||||||
|
### Golemancy Skills (Fabricator)
|
||||||
|
|
||||||
|
| Skill | Max | Effect | Base Cost | Study Time | Attunement Req |
|
||||||
|
|-------|-----|--------|-----------|------------|----------------|
|
||||||
|
| Golem Mastery | 5 | +10% golem damage/level | 300 | 6h | Fabricator 2 |
|
||||||
|
| Golem Efficiency | 5 | +5% attack speed/level | 350 | 6h | Fabricator 2 |
|
||||||
|
| Golem Longevity | 3 | +1 floor duration/level | 500 | 8h | Fabricator 3 |
|
||||||
|
| Golem Siphon | 3 | -10% maintenance/level | 400 | 8h | Fabricator 3 |
|
||||||
|
| Advanced Golemancy | 1 | Unlock hybrid recipes | 800 | 16h | Fabricator 5 |
|
||||||
|
| Golem Resonance | 1 | +1 golem slot | 1200 | 24h | Fabricator 8 |
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Advanced Golemancy: Golem Mastery 3
|
||||||
|
- Golem Resonance: Golem Mastery 5
|
||||||
|
|
||||||
|
### Effect Research Skills (Enchanter)
|
||||||
|
|
||||||
|
All effect research skills are **max level 1** and unlock specific enchantment effects.
|
||||||
|
|
||||||
|
#### Tier 1 Research (Basic Spells)
|
||||||
|
| Skill | Unlocks | Study Time |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| Mana Spell Research | Mana Strike enchantment | 4h |
|
||||||
|
| Fire Spell Research | Ember Shot, Fireball | 6h |
|
||||||
|
| Water Spell Research | Water Jet, Ice Shard | 6h |
|
||||||
|
| Air Spell Research | Gust, Wind Slash | 6h |
|
||||||
|
| Earth Spell Research | Stone Bullet, Rock Spike | 6h |
|
||||||
|
| Light Spell Research | Light Lance, Radiance | 8h |
|
||||||
|
| Dark Spell Research | Shadow Bolt, Dark Pulse | 8h |
|
||||||
|
| Death Research | Drain enchantment | 8h |
|
||||||
|
|
||||||
|
#### Tier 2 Research (Advanced Spells)
|
||||||
|
Requires Enchanter 3+ and parent element research.
|
||||||
|
|
||||||
|
| Skill | Unlocks | Study Time |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| Advanced Fire Research | Inferno, Flame Wave | 12h |
|
||||||
|
| Advanced Water Research | Tidal Wave, Ice Storm | 12h |
|
||||||
|
| Advanced Air Research | Hurricane, Wind Blade | 12h |
|
||||||
|
| Advanced Earth Research | Earthquake, Stone Barrage | 12h |
|
||||||
|
| Advanced Light Research | Solar Flare, Divine Smite | 14h |
|
||||||
|
| Advanced Dark Research | Void Rift, Shadow Storm | 14h |
|
||||||
|
|
||||||
|
#### Tier 3 Research (Master Spells)
|
||||||
|
Requires Enchanter 5+ and advanced research.
|
||||||
|
|
||||||
|
| Skill | Unlocks | Study Time |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| Master Fire Research | Pyroclasm | 24h |
|
||||||
|
| Master Water Research | Tsunami | 24h |
|
||||||
|
| Master Earth Research | Meteor Strike | 26h |
|
||||||
|
|
||||||
|
#### Compound Element Research
|
||||||
|
Requires parent element research + Enchanter 3+.
|
||||||
|
|
||||||
|
| Skill | Unlocks | Study Time |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| Metal Spell Research | Metal Shard, Iron Fist | 6h |
|
||||||
|
| Sand Spell Research | Sand Blast, Sandstorm | 6h |
|
||||||
|
| Lightning Spell Research | Spark, Lightning Bolt | 6h |
|
||||||
|
| Advanced Metal Research | Steel Tempest | 12h |
|
||||||
|
| Advanced Sand Research | Desert Wind | 12h |
|
||||||
|
| Advanced Lightning Research | Chain Lightning, Storm Call | 12h |
|
||||||
|
| Master Metal Research | Furnace Blast | 26h |
|
||||||
|
| Master Sand Research | Dune Collapse | 26h |
|
||||||
|
| Master Lightning Research | Thunder Strike | 26h |
|
||||||
|
|
||||||
|
#### Utility Research
|
||||||
|
|
||||||
|
| Skill | Unlocks | Study Time |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| Transference Spell Research | Transfer Strike, Mana Rip | 5h |
|
||||||
|
| Advanced Transference Research | Essence Drain | 12h |
|
||||||
|
| Master Transference Research | Soul Transfer | 26h |
|
||||||
|
|
||||||
|
#### Effect Research
|
||||||
|
|
||||||
|
| Skill | Unlocks | Study Time |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| Damage Effect Research | Minor/Moderate Power, Amplification | 5h |
|
||||||
|
| Combat Effect Research | Sharp Edge, Swift Casting | 6h |
|
||||||
|
| Mana Effect Research | Mana Reserve, Trickle, Mana Tap | 4h |
|
||||||
|
| Advanced Mana Research | Mana Reservoir, Stream, River | 8h |
|
||||||
|
| Utility Effect Research | Meditative Focus, Quick Study | 6h |
|
||||||
|
| Special Effect Research | Echo Chamber, Siphoning, Bane | 10h |
|
||||||
|
| Overpower Research | Overpower effect | 12h |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade Trees
|
||||||
|
|
||||||
|
### Mana Well Upgrade Tree
|
||||||
|
|
||||||
|
#### Tier 1 Upgrades
|
||||||
|
|
||||||
|
**Level 5 Choices:**
|
||||||
|
```
|
||||||
|
├── Expanded Capacity (+25% max mana)
|
||||||
|
│ └── Level 10: Deep Reservoir (+50% max mana) [replaces]
|
||||||
|
│
|
||||||
|
├── Natural Spring (+0.5 regen/hour)
|
||||||
|
│ └── Level 10: Flowing Spring (+1.5 regen) [replaces]
|
||||||
|
│
|
||||||
|
├── Mana Threshold (+30% max mana, -10% regen)
|
||||||
|
│ └── Level 10: Mana Conversion (5% max → click bonus)
|
||||||
|
│
|
||||||
|
└── Desperate Wells (+50% regen when below 25% mana)
|
||||||
|
└── Level 10: Panic Reserve (+100% regen below 10%)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Level 10 Additional Choices:**
|
||||||
|
- Mana Echo (10% chance double mana from clicks)
|
||||||
|
- Emergency Reserve (Keep 10% mana on loop reset)
|
||||||
|
- Deep Wellspring (+50% meditation efficiency)
|
||||||
|
|
||||||
|
#### Tier 2 Upgrades (Deep Reservoir)
|
||||||
|
- Abyssal Depth (+50% max mana)
|
||||||
|
- Ancient Well (+500 starting mana per loop)
|
||||||
|
- Mana Condense (+1% max per 1000 gathered)
|
||||||
|
- Deep Reserve (+0.5 regen per 100 max mana)
|
||||||
|
- Ocean of Mana (+1000 max mana)
|
||||||
|
- Mana Tide (Regen pulses ±50%)
|
||||||
|
- Void Storage (Store 150% max temporarily)
|
||||||
|
- Mana Core (0.5% max mana as regen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Mana Flow Upgrade Tree
|
||||||
|
|
||||||
|
#### Tier 1 Upgrades
|
||||||
|
|
||||||
|
**Level 5 Choices:**
|
||||||
|
```
|
||||||
|
├── Rapid Flow (+25% regen speed)
|
||||||
|
│ └── Level 10: Mana Torrent (+50% regen above 75% mana)
|
||||||
|
│
|
||||||
|
├── Steady Stream (Immune to incursion penalty)
|
||||||
|
│ └── Level 10: Eternal Flow (Immune to all penalties)
|
||||||
|
│
|
||||||
|
├── Mana Cascade (+0.1 regen per 100 max mana)
|
||||||
|
│ └── Level 10: Mana Waterfall (+0.25 per 100 max) [replaces]
|
||||||
|
│
|
||||||
|
└── Mana Overflow (Raw mana can exceed max by 20%)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Level 10 Additional Choices:**
|
||||||
|
- Ambient Absorption (+1 permanent regen)
|
||||||
|
- Flow Surge (Clicks boost regen for 1 hour)
|
||||||
|
- Flow Mastery (+10% mana from all sources)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Elemental Attunement Upgrade Tree
|
||||||
|
|
||||||
|
#### Tier 1 Upgrades
|
||||||
|
|
||||||
|
**Level 5 Choices:**
|
||||||
|
```
|
||||||
|
├── Expanded Attunement (+25% element cap)
|
||||||
|
│ └── Level 10: Element Master (+50% element cap) [replaces]
|
||||||
|
│
|
||||||
|
├── Elemental Surge (+15% elemental spell damage)
|
||||||
|
│ └── Level 10: Elemental Power (+30% damage) [replaces]
|
||||||
|
│
|
||||||
|
└── Elemental Affinity (New elements start with 10 capacity)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Level 10 Additional Choices:**
|
||||||
|
- Elemental Resonance (Spell use restores element)
|
||||||
|
- Exotic Mastery (+20% exotic element damage)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Quick Learner Upgrade Tree
|
||||||
|
|
||||||
|
#### Tier 1 Upgrades
|
||||||
|
|
||||||
|
**Level 5 Choices:**
|
||||||
|
```
|
||||||
|
├── Deep Focus (+25% study speed)
|
||||||
|
│ └── Level 10: Deep Concentration (+50% speed) [replaces]
|
||||||
|
│
|
||||||
|
├── Quick Grasp (5% chance double study progress)
|
||||||
|
│ └── Level 10: Knowledge Echo (15% instant complete)
|
||||||
|
│
|
||||||
|
├── Parallel Study (Study 2 things at 50% speed each)
|
||||||
|
│
|
||||||
|
└── Quick Mastery (-20% time for final 3 levels)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Level 10 Additional Choices:**
|
||||||
|
- Study Momentum (+5% speed per hour, max 50%)
|
||||||
|
- Knowledge Transfer (New skills start at 10% progress)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Focused Mind Upgrade Tree
|
||||||
|
|
||||||
|
#### Tier 1 Upgrades
|
||||||
|
|
||||||
|
**Level 5 Choices:**
|
||||||
|
```
|
||||||
|
├── Mind Efficiency (+25% cost reduction)
|
||||||
|
│ └── Level 10: Efficient Learning (-15% study cost) [replaces]
|
||||||
|
│
|
||||||
|
├── Mental Clarity (+10% speed when mana > 75%)
|
||||||
|
│ └── Level 10: Study Rush (First hour 2x speed)
|
||||||
|
│
|
||||||
|
└── Study Refund (25% mana back on completion)
|
||||||
|
└── Level 10: Deep Understanding (+10% skill bonuses)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Level 10 Additional Choices:**
|
||||||
|
- Chain Study (-5% cost per maxed skill)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Enchanting Upgrade Tree
|
||||||
|
|
||||||
|
#### Tier 1 Upgrades
|
||||||
|
|
||||||
|
**Level 5 Choices:**
|
||||||
|
```
|
||||||
|
├── Enchantment Capacity (+20% equipment capacity)
|
||||||
|
├── Swift Enchanting (-15% design time)
|
||||||
|
│
|
||||||
|
└── Quality Control (+10% effect power)
|
||||||
|
└── Level 10: Perfect Refinement (+25% power) [replaces]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Level 10 Additional Choices:**
|
||||||
|
- Enchantment Mastery (2 designs in progress)
|
||||||
|
- Mana Preservation (25% chance free enchant)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Golem Mastery Upgrade Tree
|
||||||
|
|
||||||
|
#### Tier 1 Upgrades
|
||||||
|
|
||||||
|
**Level 5 Choices:**
|
||||||
|
```
|
||||||
|
├── Golem Power (+25% golem damage)
|
||||||
|
├── Golem Durability (+1 floor duration)
|
||||||
|
│
|
||||||
|
└── Efficient Summons (-20% summon cost)
|
||||||
|
└── Level 10: Golem Siphon (-30% maintenance)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Level 10 Additional Choices:**
|
||||||
|
- Golem Fury (+50% attack speed for first 2 floors)
|
||||||
|
- Golem Resonance (Golems share 10% damage)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Other Skill Upgrade Trees
|
||||||
|
|
||||||
|
#### Mana Overflow (Max 5)
|
||||||
|
- **Level 5:** Click Surge (+50% click mana above 90% mana)
|
||||||
|
- **Tier 2 Level 5:** Mana Flood (+75% click mana above 75% mana)
|
||||||
|
|
||||||
|
#### Efficient Enchant (Max 5)
|
||||||
|
- **Level 5:** Thrifty Enchanter (+10% free enchant chance)
|
||||||
|
- **Tier 2 Level 5:** Optimized Enchanting (+25% free chance)
|
||||||
|
|
||||||
|
#### Enchant Speed (Max 5)
|
||||||
|
- **Level 5:** Hasty Enchanter (+25% speed for repeat designs)
|
||||||
|
- **Tier 2 Level 5:** Instant Designs (10% instant completion)
|
||||||
|
|
||||||
|
#### Essence Refining (Max 5)
|
||||||
|
- **Level 5:** Pure Essence (+25% power for tier 1 enchants)
|
||||||
|
- **Tier 2 Level 5:** Perfect Essence (+50% all enchant power)
|
||||||
|
|
||||||
|
#### Efficient Crafting (Max 5)
|
||||||
|
- **Level 5:** Batch Crafting (2 items at 75% speed each)
|
||||||
|
- **Tier 2 Level 5:** Mass Production (3 items at full speed)
|
||||||
|
|
||||||
|
#### Field Repair (Max 5)
|
||||||
|
- **Level 5:** Scavenge (Recover 10% materials from broken items)
|
||||||
|
- **Tier 2 Level 5:** Reclaim (Recover 25% materials)
|
||||||
|
|
||||||
|
#### Golem Efficiency (Max 5)
|
||||||
|
- **Level 5:** Rapid Strikes (+25% speed for first 3 floors)
|
||||||
|
- **Tier 2 Level 5:** Blitz Attack (+50% speed for first 5 floors)
|
||||||
|
|
||||||
|
#### Insight Harvest (Max 5)
|
||||||
|
- **Level 5:** Insight Bounty (+25% insight from guardians)
|
||||||
|
- **Tier 2 Level 5:** Greater Harvest (+50% insight from all sources)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier System
|
||||||
|
|
||||||
|
### How Tiers Work
|
||||||
|
|
||||||
|
1. **Reach max level** (10 for most skills, 5 for specialized)
|
||||||
|
2. **Meet attunement requirements**
|
||||||
|
3. **Tier up** - Skill resets to level 1 with 10x power multiplier
|
||||||
|
|
||||||
|
### Tier Power Scaling
|
||||||
|
|
||||||
|
| Tier | Multiplier | Level 1 Power = |
|
||||||
|
|------|------------|-----------------|
|
||||||
|
| 1 | 1x | Base |
|
||||||
|
| 2 | 10x | Tier 1 Level 10 |
|
||||||
|
| 3 | 100x | Tier 2 Level 10 |
|
||||||
|
| 4 | 1000x | Tier 3 Level 10 |
|
||||||
|
| 5 | 10000x | Tier 4 Level 10 |
|
||||||
|
|
||||||
|
### Tier Up Requirements
|
||||||
|
|
||||||
|
#### Core Skills (Mana, Study)
|
||||||
|
| Tier | Requirement |
|
||||||
|
|------|-------------|
|
||||||
|
| 1→2 | Any attunement level 3 |
|
||||||
|
| 2→3 | Any attunement level 5 |
|
||||||
|
| 3→4 | Any attunement level 7 |
|
||||||
|
| 4→5 | Any attunement level 10 |
|
||||||
|
|
||||||
|
#### Enchanter Skills
|
||||||
|
| Tier | Requirement |
|
||||||
|
|------|-------------|
|
||||||
|
| 1→2 | Enchanter level 3 |
|
||||||
|
| 2→3 | Enchanter level 5 |
|
||||||
|
| 3→4 | Enchanter level 7 |
|
||||||
|
| 4→5 | Enchanter level 10 |
|
||||||
|
|
||||||
|
#### Fabricator Skills (Golemancy)
|
||||||
|
| Tier | Requirement |
|
||||||
|
|------|-------------|
|
||||||
|
| 1→2 | Fabricator level 3 |
|
||||||
|
| 2→3 | Fabricator level 5 |
|
||||||
|
| 3→4 | Fabricator level 7 |
|
||||||
|
| 4→5 | Fabricator level 10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Banned Content
|
||||||
|
|
||||||
|
The following effects/mechanics are **NOT allowed** in skill upgrades:
|
||||||
|
|
||||||
|
| Banned Effect | Reason |
|
||||||
|
|---------------|--------|
|
||||||
|
| Lifesteal | Player cannot take damage |
|
||||||
|
| Healing (for player) | Player cannot take damage |
|
||||||
|
| Life/Blood/Wood/Mental/Force mana | Removed elements |
|
||||||
|
| Execution effects | Bypasses gameplay mechanics |
|
||||||
|
| Instant finishing | Skips mechanics |
|
||||||
|
| Direct spell damage bonuses | Spells only via weapons |
|
||||||
|
| Familiar system | Replaced by golemancy |
|
||||||
|
|
||||||
|
### Design Philosophy
|
||||||
|
|
||||||
|
1. **Player cannot take damage** - Only floors/enemies have HP
|
||||||
|
2. **No healing needed** - Player health doesn't exist
|
||||||
|
3. **Weapons matter** - Player attacks through enchanted weapons
|
||||||
|
4. **Golems fight** - Fabricator's constructs do the combat
|
||||||
|
5. **Enchantments empower** - Enchanter enhances equipment
|
||||||
|
6. **Pacts grant power** - Invoker makes deals with guardians
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Progression
|
||||||
|
|
||||||
|
### Mana Well Complete Journey
|
||||||
|
|
||||||
|
1. **Level 1-4:** +400 max mana (100 per level)
|
||||||
|
2. **Level 5:** Choose "Expanded Capacity" (+25% max)
|
||||||
|
- Total: 500 base + 125 bonus = 625 max mana
|
||||||
|
3. **Level 6-9:** +400 more max mana
|
||||||
|
4. **Level 10:** Choose "Deep Reservoir" (replaces to +50%)
|
||||||
|
- Total: 1000 base + 500 bonus = 1500 max mana
|
||||||
|
5. **Tier Up to Tier 2:** Mana Well becomes "Deep Reservoir"
|
||||||
|
6. **Tier 2 Level 1:** 100 × 10 = 1000 base (same as T1 L10)
|
||||||
|
7. **Tier 2 Level 5:** Choose "Abyssal Depth" (+50% max)
|
||||||
|
8. **Continue progression...**
|
||||||
|
|
||||||
|
### Total Power at Tier 2 Level 5:
|
||||||
|
- Base: 500 × 10 = 5000 max mana
|
||||||
|
- Upgrades: +50% from Tier 1 +50% from Tier 2 = +100%
|
||||||
|
- Total: 5000 × 2 = **10,000 max mana**
|
||||||
1
download/README.md
Executable file
1
download/README.md
Executable file
@@ -0,0 +1 @@
|
|||||||
|
Here are all the generated files.
|
||||||
50
eslint.config.mjs
Executable file
50
eslint.config.mjs
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTypescript from "eslint-config-next/typescript";
|
||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
|
||||||
|
rules: {
|
||||||
|
// TypeScript rules
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "off",
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"@typescript-eslint/prefer-as-const": "off",
|
||||||
|
"@typescript-eslint/no-unused-disable-directive": "off",
|
||||||
|
|
||||||
|
// React rules
|
||||||
|
"react-hooks/exhaustive-deps": "off",
|
||||||
|
"react-hooks/purity": "off",
|
||||||
|
"react/no-unescaped-entities": "off",
|
||||||
|
"react/display-name": "off",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"react-compiler/react-compiler": "off",
|
||||||
|
|
||||||
|
// Next.js rules
|
||||||
|
"@next/next/no-img-element": "off",
|
||||||
|
"@next/next/no-html-link-for-pages": "off",
|
||||||
|
|
||||||
|
// General JavaScript rules
|
||||||
|
"prefer-const": "off",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"no-console": "off",
|
||||||
|
"no-debugger": "off",
|
||||||
|
"no-empty": "off",
|
||||||
|
"no-irregular-whitespace": "off",
|
||||||
|
"no-case-declarations": "off",
|
||||||
|
"no-fallthrough": "off",
|
||||||
|
"no-mixed-spaces-and-tabs": "off",
|
||||||
|
"no-redeclare": "off",
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-unreachable": "off",
|
||||||
|
"no-useless-escape": "off",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills"]
|
||||||
|
}];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
196
examples/websocket/frontend.tsx
Executable file
196
examples/websocket/frontend.tsx
Executable file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: Date | string;
|
||||||
|
type: 'user' | 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SocketDemo() {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [inputMessage, setInputMessage] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [isUsernameSet, setIsUsernameSet] = useState(false);
|
||||||
|
const [socket, setSocket] = useState<any>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Connect to websocket server
|
||||||
|
// Never use PORT in the URL, alyways use XTransformPort
|
||||||
|
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
|
||||||
|
const socketInstance = io('/?XTransformPort=3003', {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
forceNew: true,
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
setSocket(socketInstance);
|
||||||
|
|
||||||
|
socketInstance.on('connect', () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on('disconnect', () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on('message', (msg: Message) => {
|
||||||
|
setMessages(prev => [...prev, msg]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on('user-joined', (data: { user: User; message: Message }) => {
|
||||||
|
setMessages(prev => [...prev, data.message]);
|
||||||
|
setUsers(prev => {
|
||||||
|
if (!prev.find(u => u.id === data.user.id)) {
|
||||||
|
return [...prev, data.user];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on('user-left', (data: { user: User; message: Message }) => {
|
||||||
|
setMessages(prev => [...prev, data.message]);
|
||||||
|
setUsers(prev => prev.filter(u => u.id !== data.user.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on('users-list', (data: { users: User[] }) => {
|
||||||
|
setUsers(data.users);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socketInstance.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleJoin = () => {
|
||||||
|
if (socket && username.trim() && isConnected) {
|
||||||
|
socket.emit('join', { username: username.trim() });
|
||||||
|
setIsUsernameSet(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
if (socket && inputMessage.trim() && username.trim()) {
|
||||||
|
socket.emit('message', {
|
||||||
|
content: inputMessage.trim(),
|
||||||
|
username: username.trim()
|
||||||
|
});
|
||||||
|
setInputMessage('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4 max-w-2xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
WebSocket Demo
|
||||||
|
<span className={`text-sm px-2 py-1 rounded ${isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||||
|
{isConnected ? 'Connected' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!isUsernameSet ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleJoin();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Enter your username..."
|
||||||
|
disabled={!isConnected}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleJoin}
|
||||||
|
disabled={!isConnected || !username.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Join Chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ScrollArea className="h-80 w-full border rounded-md p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center">No messages yet</p>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => (
|
||||||
|
<div key={msg.id} className="border-b pb-2 last:border-b-0">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className={`text-sm font-medium ${msg.type === 'system'
|
||||||
|
? 'text-blue-600 italic'
|
||||||
|
: 'text-gray-700'
|
||||||
|
}`}>
|
||||||
|
{msg.username}
|
||||||
|
</p>
|
||||||
|
<p className={`${msg.type === 'system'
|
||||||
|
? 'text-blue-500 italic'
|
||||||
|
: 'text-gray-900'
|
||||||
|
}`}>
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Input
|
||||||
|
value={inputMessage}
|
||||||
|
onChange={(e) => setInputMessage(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
disabled={!isConnected}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={sendMessage}
|
||||||
|
disabled={!isConnected || !inputMessage.trim()}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
examples/websocket/server.ts
Executable file
138
examples/websocket/server.ts
Executable file
@@ -0,0 +1,138 @@
|
|||||||
|
import { createServer } from 'http'
|
||||||
|
import { Server } from 'socket.io'
|
||||||
|
|
||||||
|
const httpServer = createServer()
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
|
||||||
|
path: '/',
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
methods: ["GET", "POST"]
|
||||||
|
},
|
||||||
|
pingTimeout: 60000,
|
||||||
|
pingInterval: 25000,
|
||||||
|
})
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
content: string
|
||||||
|
timestamp: Date
|
||||||
|
type: 'user' | 'system'
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = new Map<string, User>()
|
||||||
|
|
||||||
|
const generateMessageId = () => Math.random().toString(36).substr(2, 9)
|
||||||
|
|
||||||
|
const createSystemMessage = (content: string): Message => ({
|
||||||
|
id: generateMessageId(),
|
||||||
|
username: 'System',
|
||||||
|
content,
|
||||||
|
timestamp: new Date(),
|
||||||
|
type: 'system'
|
||||||
|
})
|
||||||
|
|
||||||
|
const createUserMessage = (username: string, content: string): Message => ({
|
||||||
|
id: generateMessageId(),
|
||||||
|
username,
|
||||||
|
content,
|
||||||
|
timestamp: new Date(),
|
||||||
|
type: 'user'
|
||||||
|
})
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
console.log(`User connected: ${socket.id}`)
|
||||||
|
|
||||||
|
// Add test event handler
|
||||||
|
socket.on('test', (data) => {
|
||||||
|
console.log('Received test message:', data)
|
||||||
|
socket.emit('test-response', {
|
||||||
|
message: 'Server received test message',
|
||||||
|
data: data,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('join', (data: { username: string }) => {
|
||||||
|
const { username } = data
|
||||||
|
|
||||||
|
// Create user object
|
||||||
|
const user: User = {
|
||||||
|
id: socket.id,
|
||||||
|
username
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to user list
|
||||||
|
users.set(socket.id, user)
|
||||||
|
|
||||||
|
// Send join message to all users
|
||||||
|
const joinMessage = createSystemMessage(`${username} joined the chat room`)
|
||||||
|
io.emit('user-joined', { user, message: joinMessage })
|
||||||
|
|
||||||
|
// Send current user list to new user
|
||||||
|
const usersList = Array.from(users.values())
|
||||||
|
socket.emit('users-list', { users: usersList })
|
||||||
|
|
||||||
|
console.log(`${username} joined the chat room, current online users: ${users.size}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('message', (data: { content: string; username: string }) => {
|
||||||
|
const { content, username } = data
|
||||||
|
const user = users.get(socket.id)
|
||||||
|
|
||||||
|
if (user && user.username === username) {
|
||||||
|
const message = createUserMessage(username, content)
|
||||||
|
io.emit('message', message)
|
||||||
|
console.log(`${username}: ${content}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
const user = users.get(socket.id)
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Remove from user list
|
||||||
|
users.delete(socket.id)
|
||||||
|
|
||||||
|
// Send leave message to all users
|
||||||
|
const leaveMessage = createSystemMessage(`${user.username} left the chat room`)
|
||||||
|
io.emit('user-left', { user: { id: socket.id, username: user.username }, message: leaveMessage })
|
||||||
|
|
||||||
|
console.log(`${user.username} left the chat room, current online users: ${users.size}`)
|
||||||
|
} else {
|
||||||
|
console.log(`User disconnected: ${socket.id}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
console.error(`Socket error (${socket.id}):`, error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const PORT = 3003
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`WebSocket server running on port ${PORT}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('Received SIGTERM signal, shutting down server...')
|
||||||
|
httpServer.close(() => {
|
||||||
|
console.log('WebSocket server closed')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('Received SIGINT signal, shutting down server...')
|
||||||
|
httpServer.close(() => {
|
||||||
|
console.log('WebSocket server closed')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
0
mini-services/.gitkeep
Executable file
0
mini-services/.gitkeep
Executable file
12
next.config.ts
Executable file
12
next.config.ts
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
/* config options here */
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
reactStrictMode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
100
package.json
Executable file
100
package.json
Executable file
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"name": "nextjs_tailwind_shadcn_ts",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3000 2>&1 | tee dev.log",
|
||||||
|
"build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
|
||||||
|
"start": "NODE_ENV=production bun .next/standalone/server.js 2>&1 | tee server.log",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:reset": "prisma migrate reset"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@mdxeditor/editor": "^3.39.1",
|
||||||
|
"@prisma/client": "^6.11.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.15",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.14",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.15",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slider": "^1.3.5",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-toast": "^1.2.14",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.9",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@reactuses/core": "^6.0.5",
|
||||||
|
"@tanstack/react-query": "^5.82.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"framer-motion": "^12.23.2",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^0.525.0",
|
||||||
|
"next": "^16.1.1",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
|
"next-intl": "^4.3.4",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"prisma": "^6.11.1",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-day-picker": "^9.8.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.60.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-resizable-panels": "^3.0.3",
|
||||||
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
|
"sharp": "^0.34.3",
|
||||||
|
"sonner": "^2.0.6",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"z-ai-web-dev-sdk": "^0.0.17",
|
||||||
|
"zod": "^4.0.2",
|
||||||
|
"zustand": "^5.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"bun-types": "^1.3.4",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "^16.1.1",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.3.5",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.mjs
Executable file
5
postcss.config.mjs
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
32
prisma/schema.prisma
Executable file
32
prisma/schema.prisma
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||||
|
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Post {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
content String?
|
||||||
|
published Boolean @default(false)
|
||||||
|
authorId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
29
public/logo.svg
Executable file
29
public/logo.svg
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||||
|
<defs>
|
||||||
|
<style type="text/css">
|
||||||
|
.st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
|
||||||
|
.st23{fill:#FFFFFF;}
|
||||||
|
|
||||||
|
.z-breathe {
|
||||||
|
animation: breathe 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes breathe {
|
||||||
|
0%, 100% { opacity: 0.7; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g>
|
||||||
|
<path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03
|
||||||
|
C28.51,26.72,26.72,28.51,24.51,28.51z"/>
|
||||||
|
<g class="z-breathe">
|
||||||
|
<path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
|
||||||
|
<polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1"/>
|
||||||
|
<path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
14
public/robots.txt
Executable file
14
public/robots.txt
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bingbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Twitterbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: facebookexternalhit
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
5
src/app/api/route.ts
Executable file
5
src/app/api/route.ts
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ message: "Hello, world!" });
|
||||||
|
}
|
||||||
300
src/app/globals.css
Executable file
300
src/app/globals.css
Executable file
@@ -0,0 +1,300 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: #060811;
|
||||||
|
--foreground: #c8d8f8;
|
||||||
|
--card: #0C1020;
|
||||||
|
--card-foreground: #c8d8f8;
|
||||||
|
--popover: #111628;
|
||||||
|
--popover-foreground: #c8d8f8;
|
||||||
|
--primary: #3B6FE8;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #1e2a45;
|
||||||
|
--secondary-foreground: #c8d8f8;
|
||||||
|
--muted: #181f35;
|
||||||
|
--muted-foreground: #7a92c0;
|
||||||
|
--accent: #2a3a60;
|
||||||
|
--accent-foreground: #c8d8f8;
|
||||||
|
--destructive: #C0392B;
|
||||||
|
--border: #1e2a45;
|
||||||
|
--input: #1e2a45;
|
||||||
|
--ring: #3B6FE8;
|
||||||
|
--chart-1: #FF6B35;
|
||||||
|
--chart-2: #4ECDC4;
|
||||||
|
--chart-3: #9B59B6;
|
||||||
|
--chart-4: #2ECC71;
|
||||||
|
--chart-5: #FFD700;
|
||||||
|
--sidebar: #0C1020;
|
||||||
|
--sidebar-foreground: #c8d8f8;
|
||||||
|
--sidebar-primary: #D4A843;
|
||||||
|
--sidebar-primary-foreground: #0C1020;
|
||||||
|
--sidebar-accent: #1e2a45;
|
||||||
|
--sidebar-accent-foreground: #c8d8f8;
|
||||||
|
--sidebar-border: #1e2a45;
|
||||||
|
--sidebar-ring: #D4A843;
|
||||||
|
|
||||||
|
/* Game-specific colors */
|
||||||
|
--game-bg: #060811;
|
||||||
|
--game-bg1: #0C1020;
|
||||||
|
--game-bg2: #111628;
|
||||||
|
--game-bg3: #181f35;
|
||||||
|
--game-border: #1e2a45;
|
||||||
|
--game-border2: #2a3a60;
|
||||||
|
--game-text: #c8d8f8;
|
||||||
|
--game-text2: #7a92c0;
|
||||||
|
--game-text3: #4a5f8a;
|
||||||
|
--game-gold: #D4A843;
|
||||||
|
--game-gold2: #A87830;
|
||||||
|
--game-purple: #7C5CBF;
|
||||||
|
--game-purpleL: #A07EE0;
|
||||||
|
--game-accent: #3B6FE8;
|
||||||
|
--game-accentL: #5B8FFF;
|
||||||
|
--game-danger: #C0392B;
|
||||||
|
--game-success: #27AE60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #060811;
|
||||||
|
--foreground: #c8d8f8;
|
||||||
|
--card: #0C1020;
|
||||||
|
--card-foreground: #c8d8f8;
|
||||||
|
--popover: #111628;
|
||||||
|
--popover-foreground: #c8d8f8;
|
||||||
|
--primary: #5B8FFF;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #1e2a45;
|
||||||
|
--secondary-foreground: #c8d8f8;
|
||||||
|
--muted: #181f35;
|
||||||
|
--muted-foreground: #7a92c0;
|
||||||
|
--accent: #2a3a60;
|
||||||
|
--accent-foreground: #c8d8f8;
|
||||||
|
--destructive: #C0392B;
|
||||||
|
--border: #1e2a45;
|
||||||
|
--input: #1e2a45;
|
||||||
|
--ring: #5B8FFF;
|
||||||
|
--chart-1: #FF6B35;
|
||||||
|
--chart-2: #4ECDC4;
|
||||||
|
--chart-3: #9B59B6;
|
||||||
|
--chart-4: #2ECC71;
|
||||||
|
--chart-5: #FFD700;
|
||||||
|
--sidebar: #0C1020;
|
||||||
|
--sidebar-foreground: #c8d8f8;
|
||||||
|
--sidebar-primary: #D4A843;
|
||||||
|
--sidebar-primary-foreground: #0C1020;
|
||||||
|
--sidebar-accent: #1e2a45;
|
||||||
|
--sidebar-accent-foreground: #c8d8f8;
|
||||||
|
--sidebar-border: #1e2a45;
|
||||||
|
--sidebar-ring: #D4A843;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: 'Crimson Text', Georgia, serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game-specific styles */
|
||||||
|
.game-root {
|
||||||
|
font-family: 'Crimson Text', Georgia, serif;
|
||||||
|
background: var(--game-bg);
|
||||||
|
color: var(--game-text);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 20% 10%, #0D1535 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 80% 90%, #0A0A20 0%, transparent 50%),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 40px, rgba(30,42,69,0.15) 40px, rgba(30,42,69,0.15) 41px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 40px, rgba(30,42,69,0.15) 40px, rgba(30,42,69,0.15) 41px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-title {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
background: linear-gradient(135deg, var(--game-gold) 0%, var(--game-purpleL) 50%, var(--game-accentL) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-panel-title {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-mono {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--game-border2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--game-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mana bar animation */
|
||||||
|
@keyframes mana-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.mana-bar-animated {
|
||||||
|
animation: mana-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow effects */
|
||||||
|
.glow-gold {
|
||||||
|
box-shadow: 0 0 15px rgba(212, 168, 67, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-purple {
|
||||||
|
box-shadow: 0 0 15px rgba(124, 92, 191, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-accent {
|
||||||
|
box-shadow: 0 0 15px rgba(60, 111, 232, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button hover effects */
|
||||||
|
.btn-game {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-game:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card hover effects */
|
||||||
|
.card-game {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-game:hover {
|
||||||
|
border-color: var(--game-border2);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Element pill styles */
|
||||||
|
.elem-pill {
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elem-pill:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar day styles */
|
||||||
|
.day-cell {
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-current {
|
||||||
|
box-shadow: 0 0 8px rgba(60, 111, 232, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-incursion {
|
||||||
|
border-color: rgba(192, 57, 43, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game over overlay */
|
||||||
|
.game-overlay {
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
background: rgba(6, 8, 17, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pact badge */
|
||||||
|
.pact-badge {
|
||||||
|
box-shadow: 0 0 10px rgba(212, 168, 67, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spell card active */
|
||||||
|
.spell-active {
|
||||||
|
border-color: var(--game-gold);
|
||||||
|
background: rgba(212, 168, 67, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab active */
|
||||||
|
.tab-active {
|
||||||
|
color: var(--game-gold);
|
||||||
|
border-bottom-color: var(--game-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skill dot filled */
|
||||||
|
.skill-dot-filled {
|
||||||
|
background: var(--game-purpleL);
|
||||||
|
box-shadow: 0 0 6px rgba(160, 126, 224, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log entry */
|
||||||
|
.log-entry-new {
|
||||||
|
color: var(--game-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry-old {
|
||||||
|
color: var(--game-text2);
|
||||||
|
}
|
||||||
41
src/app/layout.tsx
Executable file
41
src/app/layout.tsx
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { DebugProvider } from "@/lib/game/debug-context";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Mana Loop",
|
||||||
|
description: "A time-loop incremental game where you climb the Spire and sign pacts with guardians.",
|
||||||
|
keywords: ["Mana Loop", "incremental game", "idle game", "time loop"],
|
||||||
|
authors: [{ name: "Mana Loop Team" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
||||||
|
>
|
||||||
|
<DebugProvider>
|
||||||
|
{children}
|
||||||
|
</DebugProvider>
|
||||||
|
<Toaster />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
465
src/app/page.tsx
Executable file
465
src/app/page.tsx
Executable file
@@ -0,0 +1,465 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useGameStore, useGameLoop, fmt, fmtDec, getFloorElement, computeMaxMana, computeRegen, computeClickMana, getMeditationBonus, getIncursionStrength, canAffordSpellCost } from '@/lib/game/store';
|
||||||
|
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
|
||||||
|
import { getDamageBreakdown } from '@/lib/game/computed-stats';
|
||||||
|
import { ELEMENTS, GUARDIANS, SPELLS_DEF, PRESTIGE_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||||
|
import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||||
|
import { getUnifiedEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
|
||||||
|
import { formatHour } from '@/lib/game/formatting';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { RotateCcw } from 'lucide-react';
|
||||||
|
import { CraftingTab, SpireTab, SpellsTab, LabTab, SkillsTab, StatsTab, EquipmentTab, AttunementsTab, DebugTab, LootTab, AchievementsTab, GolemancyTab } from '@/components/game/tabs';
|
||||||
|
import { ActionButtons, CalendarDisplay, ManaDisplay, TimeDisplay } from '@/components/game';
|
||||||
|
// Loot and Achievements moved to separate tabs
|
||||||
|
import { DebugName } from '@/lib/game/debug-context';
|
||||||
|
|
||||||
|
export default function ManaLoopGame() {
|
||||||
|
const [activeTab, setActiveTab] = useState('spire');
|
||||||
|
const [isGathering, setIsGathering] = useState(false);
|
||||||
|
|
||||||
|
// Game store
|
||||||
|
const store = useGameStore();
|
||||||
|
const gameLoop = useGameLoop();
|
||||||
|
|
||||||
|
// Computed effects from upgrades and equipment
|
||||||
|
const upgradeEffects = getUnifiedEffects(store);
|
||||||
|
|
||||||
|
// Derived stats
|
||||||
|
const maxMana = computeMaxMana(store, upgradeEffects);
|
||||||
|
const baseRegen = computeRegen(store, upgradeEffects);
|
||||||
|
const clickMana = computeClickMana(store);
|
||||||
|
const floorElem = getFloorElement(store.currentFloor);
|
||||||
|
const floorElemDef = ELEMENTS[floorElem];
|
||||||
|
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
|
||||||
|
const currentGuardian = GUARDIANS[store.currentFloor];
|
||||||
|
const meditationMultiplier = getMeditationBonus(store.meditateTicks, store.skills, upgradeEffects.meditationEfficiency);
|
||||||
|
const incursionStrength = getIncursionStrength(store.day, store.hour);
|
||||||
|
const studySpeedMult = getStudySpeedMultiplier(store.skills);
|
||||||
|
const studyCostMult = getStudyCostMultiplier(store.skills);
|
||||||
|
|
||||||
|
// Effective regen with incursion penalty
|
||||||
|
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
|
||||||
|
|
||||||
|
// Mana Cascade bonus
|
||||||
|
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
|
||||||
|
? Math.floor(maxMana / 100) * 0.1
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Effective regen
|
||||||
|
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
|
||||||
|
|
||||||
|
// Get all active spells from equipment
|
||||||
|
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
|
||||||
|
|
||||||
|
// Compute total DPS
|
||||||
|
const totalDPS = getTotalDPS(store, upgradeEffects, floorElem);
|
||||||
|
|
||||||
|
// Auto-gather while holding
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGathering) return;
|
||||||
|
|
||||||
|
let lastGatherTime = 0;
|
||||||
|
const minGatherInterval = 100;
|
||||||
|
let animationFrameId: number;
|
||||||
|
|
||||||
|
const gatherLoop = (timestamp: number) => {
|
||||||
|
if (timestamp - lastGatherTime >= minGatherInterval) {
|
||||||
|
store.gatherMana();
|
||||||
|
lastGatherTime = timestamp;
|
||||||
|
}
|
||||||
|
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||||
|
return () => cancelAnimationFrame(animationFrameId);
|
||||||
|
}, [isGathering, store]);
|
||||||
|
|
||||||
|
// Handle gather button events
|
||||||
|
const handleGatherStart = () => {
|
||||||
|
setIsGathering(true);
|
||||||
|
store.gatherMana();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGatherEnd = () => {
|
||||||
|
setIsGathering(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start game loop
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = gameLoop.start();
|
||||||
|
return cleanup;
|
||||||
|
}, [gameLoop]);
|
||||||
|
|
||||||
|
// Check if spell can be cast
|
||||||
|
const canCastSpell = (spellId: string): boolean => {
|
||||||
|
const spell = SPELLS_DEF[spellId];
|
||||||
|
if (!spell) return false;
|
||||||
|
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Game Over Screen
|
||||||
|
if (store.gameOver) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 game-overlay flex items-center justify-center z-50">
|
||||||
|
<Card className="bg-gray-900 border-gray-600 max-w-md w-full mx-4 shadow-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className={`text-3xl text-center game-title ${store.victory ? 'text-amber-400' : 'text-red-400'}`}>
|
||||||
|
{store.victory ? 'VICTORY!' : 'LOOP ENDS'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-center text-gray-400">
|
||||||
|
{store.victory
|
||||||
|
? 'The Awakened One falls! Your power echoes through eternity.'
|
||||||
|
: 'The time loop resets... but you remember.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 bg-gray-800 rounded">
|
||||||
|
<div className="text-xl font-bold text-amber-400 game-mono">{fmt(store.loopInsight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Insight Gained</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded">
|
||||||
|
<div className="text-xl font-bold text-blue-400 game-mono">{store.maxFloorReached}</div>
|
||||||
|
<div className="text-xs text-gray-400">Best Floor</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded">
|
||||||
|
<div className="text-xl font-bold text-purple-400 game-mono">{store.signedPacts.length}</div>
|
||||||
|
<div className="text-xs text-gray-400">Pacts Signed</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded">
|
||||||
|
<div className="text-xl font-bold text-green-400 game-mono">{store.loopCount + 1}</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Loops</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => store.startNewLoop()}
|
||||||
|
>
|
||||||
|
Begin New Loop
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="game-root min-h-screen flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-50 bg-gradient-to-b from-gray-900 to-gray-900/80 border-b border-gray-700 px-4 py-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold game-title tracking-wider">MANA LOOP</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<TimeDisplay
|
||||||
|
day={store.day}
|
||||||
|
hour={store.hour}
|
||||||
|
isPaused={store.isPaused}
|
||||||
|
togglePause={store.togglePause}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 flex flex-col md:flex-row gap-4 p-4">
|
||||||
|
{/* Left Panel - Mana & Actions */}
|
||||||
|
<div className="md:w-80 space-y-4 flex-shrink-0">
|
||||||
|
{/* Mana Display */}
|
||||||
|
<DebugName name="ManaDisplay">
|
||||||
|
<ManaDisplay
|
||||||
|
rawMana={store.rawMana}
|
||||||
|
maxMana={maxMana}
|
||||||
|
effectiveRegen={effectiveRegen}
|
||||||
|
meditationMultiplier={meditationMultiplier}
|
||||||
|
clickMana={clickMana}
|
||||||
|
isGathering={isGathering}
|
||||||
|
onGatherStart={handleGatherStart}
|
||||||
|
onGatherEnd={handleGatherEnd}
|
||||||
|
elements={store.elements}
|
||||||
|
/>
|
||||||
|
</DebugName>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<DebugName name="ActionButtons">
|
||||||
|
<ActionButtons
|
||||||
|
currentAction={store.currentAction}
|
||||||
|
designProgress={store.designProgress}
|
||||||
|
preparationProgress={store.preparationProgress}
|
||||||
|
applicationProgress={store.applicationProgress}
|
||||||
|
setAction={store.setAction}
|
||||||
|
/>
|
||||||
|
</DebugName>
|
||||||
|
|
||||||
|
{/* Calendar */}
|
||||||
|
<DebugName name="CalendarDisplay">
|
||||||
|
<CalendarDisplay
|
||||||
|
day={store.day}
|
||||||
|
hour={store.hour}
|
||||||
|
incursionStrength={incursionStrength}
|
||||||
|
/>
|
||||||
|
</DebugName>
|
||||||
|
|
||||||
|
{/* Loot and Achievements moved to tabs */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Tabs */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="flex flex-wrap gap-1 w-full mb-4 h-auto">
|
||||||
|
<TabsTrigger value="spire" className="text-xs px-2 py-1">⚔️ Spire</TabsTrigger>
|
||||||
|
<TabsTrigger value="attunements" className="text-xs px-2 py-1">✨ Attune</TabsTrigger>
|
||||||
|
<TabsTrigger value="golemancy" className="text-xs px-2 py-1">🗿 Golems</TabsTrigger>
|
||||||
|
<TabsTrigger value="skills" className="text-xs px-2 py-1">📚 Skills</TabsTrigger>
|
||||||
|
<TabsTrigger value="spells" className="text-xs px-2 py-1">🔮 Spells</TabsTrigger>
|
||||||
|
<TabsTrigger value="equipment" className="text-xs px-2 py-1">🛡️ Gear</TabsTrigger>
|
||||||
|
<TabsTrigger value="crafting" className="text-xs px-2 py-1">🔧 Craft</TabsTrigger>
|
||||||
|
<TabsTrigger value="loot" className="text-xs px-2 py-1">💎 Loot</TabsTrigger>
|
||||||
|
<TabsTrigger value="achievements" className="text-xs px-2 py-1">🏆 Achieve</TabsTrigger>
|
||||||
|
<TabsTrigger value="lab" className="text-xs px-2 py-1">🔬 Lab</TabsTrigger>
|
||||||
|
<TabsTrigger value="stats" className="text-xs px-2 py-1">📊 Stats</TabsTrigger>
|
||||||
|
<TabsTrigger value="debug" className="text-xs px-2 py-1">🔧 Debug</TabsTrigger>
|
||||||
|
<TabsTrigger value="grimoire" className="text-xs px-2 py-1">📖 Grimoire</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="spire">
|
||||||
|
<DebugName name="SpireTab">
|
||||||
|
<SpireTab store={store} />
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="attunements">
|
||||||
|
<DebugName name="AttunementsTab">
|
||||||
|
<AttunementsTab store={store} />
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="golemancy">
|
||||||
|
<DebugName name="GolemancyTab">
|
||||||
|
<GolemancyTab store={store} />
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="skills">
|
||||||
|
<DebugName name="SkillsTab">
|
||||||
|
<SkillsTab store={store} />
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="spells">
|
||||||
|
<DebugName name="SpellsTab">
|
||||||
|
<SpellsTab store={store} />
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="equipment">
|
||||||
|
<DebugName name="EquipmentTab">
|
||||||
|
<EquipmentTab store={store} />
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="crafting">
|
||||||
|
<DebugName name="CraftingTab">
|
||||||
|
<CraftingTab store={store} />
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="loot">
|
||||||
|
<DebugName name="LootTab">
|
||||||
|
<LootTab store={store} />
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="achievements">
|
||||||
|
<DebugName name="AchievementsTab">
|
||||||
|
<AchievementsTab store={store} />
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="lab">
|
||||||
|
<DebugName name="LabTab">
|
||||||
|
<LabTab store={store} />
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="stats">
|
||||||
|
<DebugName name="StatsTab">
|
||||||
|
<StatsTab
|
||||||
|
store={store}
|
||||||
|
upgradeEffects={upgradeEffects}
|
||||||
|
maxMana={maxMana}
|
||||||
|
baseRegen={baseRegen}
|
||||||
|
clickMana={clickMana}
|
||||||
|
meditationMultiplier={meditationMultiplier}
|
||||||
|
effectiveRegen={effectiveRegen}
|
||||||
|
incursionStrength={incursionStrength}
|
||||||
|
manaCascadeBonus={manaCascadeBonus}
|
||||||
|
studySpeedMult={studySpeedMult}
|
||||||
|
studyCostMult={studyCostMult}
|
||||||
|
/>
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="grimoire">
|
||||||
|
<DebugName name="GrimoireTab">
|
||||||
|
{renderGrimoireTab()}
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="debug">
|
||||||
|
<DebugName name="DebugTab">
|
||||||
|
<DebugTab store={store} />
|
||||||
|
</DebugName>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grimoire Tab (Prestige)
|
||||||
|
function renderGrimoireTab() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Current Status */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||||
|
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Current Insight</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Insight</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
|
||||||
|
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Signed Pacts */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Signed Pacts</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{store.signedPacts.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-sm">No pacts signed yet. Defeat guardians to earn pacts.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{store.signedPacts.map((floor) => {
|
||||||
|
const guardian = GUARDIANS[floor];
|
||||||
|
if (!guardian) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={floor}
|
||||||
|
className="flex items-center justify-between p-2 rounded border"
|
||||||
|
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}15` }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||||
|
{guardian.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Floor {floor}</div>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-amber-900/50 text-amber-300">
|
||||||
|
{guardian.pact}x multiplier
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Prestige Upgrades */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Insight Upgrades (Permanent)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
|
||||||
|
const level = store.prestigeUpgrades[id] || 0;
|
||||||
|
const maxed = level >= def.max;
|
||||||
|
const canBuy = !maxed && store.insight >= def.cost;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="p-3 rounded border border-gray-700 bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{level}/{def.max}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={canBuy ? 'default' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canBuy}
|
||||||
|
onClick={() => store.doPrestige(id)}
|
||||||
|
>
|
||||||
|
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Game Button */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-400">Reset All Progress</div>
|
||||||
|
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
|
||||||
|
store.resetGame();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import TooltipProvider
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
173
src/components/game/AchievementsDisplay.tsx
Executable file
173
src/components/game/AchievementsDisplay.tsx
Executable file
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Trophy, Lock, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import type { AchievementState } from '@/lib/game/types';
|
||||||
|
import { ACHIEVEMENTS, ACHIEVEMENT_CATEGORY_COLORS, getAchievementsByCategory, isAchievementRevealed } from '@/lib/game/data/achievements';
|
||||||
|
import { GameState } from '@/lib/game/types';
|
||||||
|
|
||||||
|
interface AchievementsProps {
|
||||||
|
achievements: AchievementState;
|
||||||
|
gameState: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'totalSpellsCast' | 'totalDamageDealt' | 'totalCraftsCompleted'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AchievementsDisplay({ achievements, gameState }: AchievementsProps) {
|
||||||
|
const [expandedCategory, setExpandedCategory] = useState<string | null>('combat');
|
||||||
|
|
||||||
|
const categories = getAchievementsByCategory();
|
||||||
|
const unlockedCount = achievements.unlocked.length;
|
||||||
|
const totalCount = Object.keys(ACHIEVEMENTS).length;
|
||||||
|
|
||||||
|
// Calculate progress for each achievement
|
||||||
|
const getProgress = (achievementId: string): number => {
|
||||||
|
const achievement = ACHIEVEMENTS[achievementId];
|
||||||
|
if (!achievement) return 0;
|
||||||
|
if (achievements.unlocked.includes(achievementId)) return achievement.requirement.value;
|
||||||
|
|
||||||
|
const { type, subType } = achievement.requirement;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'floor':
|
||||||
|
if (subType === 'noPacts') {
|
||||||
|
return gameState.maxFloorReached >= achievement.requirement.value && gameState.signedPacts.length === 0
|
||||||
|
? achievement.requirement.value
|
||||||
|
: gameState.maxFloorReached;
|
||||||
|
}
|
||||||
|
return gameState.maxFloorReached;
|
||||||
|
case 'spells':
|
||||||
|
return gameState.totalSpellsCast || 0;
|
||||||
|
case 'damage':
|
||||||
|
return gameState.totalDamageDealt || 0;
|
||||||
|
case 'mana':
|
||||||
|
return gameState.totalManaGathered || 0;
|
||||||
|
case 'pact':
|
||||||
|
return gameState.signedPacts.length;
|
||||||
|
case 'craft':
|
||||||
|
return gameState.totalCraftsCompleted || 0;
|
||||||
|
default:
|
||||||
|
return achievements.progress[achievementId] || 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Trophy className="w-4 h-4" />
|
||||||
|
Achievements
|
||||||
|
<Badge className="ml-auto bg-amber-900/50 text-amber-300">
|
||||||
|
{unlockedCount} / {totalCount}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(categories).map(([category, categoryAchievements]) => (
|
||||||
|
<div key={category} className="space-y-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-between text-xs"
|
||||||
|
onClick={() => setExpandedCategory(expandedCategory === category ? null : category)}
|
||||||
|
>
|
||||||
|
<span style={{ color: ACHIEVEMENT_CATEGORY_COLORS[category] }}>
|
||||||
|
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{categoryAchievements.filter(a => achievements.unlocked.includes(a.id)).length} / {categoryAchievements.length}
|
||||||
|
</span>
|
||||||
|
{expandedCategory === category ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{expandedCategory === category && (
|
||||||
|
<div className="pl-2 space-y-2">
|
||||||
|
{categoryAchievements.map((achievement) => {
|
||||||
|
const isUnlocked = achievements.unlocked.includes(achievement.id);
|
||||||
|
const progress = getProgress(achievement.id);
|
||||||
|
const isRevealed = isAchievementRevealed(achievement, progress);
|
||||||
|
const progressPercent = Math.min(100, (progress / achievement.requirement.value) * 100);
|
||||||
|
|
||||||
|
if (!isRevealed && !isUnlocked) {
|
||||||
|
return (
|
||||||
|
<div key={achievement.id} className="p-2 rounded bg-gray-800/30 border border-gray-700">
|
||||||
|
<div className="flex items-center gap-2 text-gray-500">
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
<span className="text-sm">???</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={achievement.id}
|
||||||
|
className={`p-2 rounded border ${
|
||||||
|
isUnlocked
|
||||||
|
? 'bg-amber-900/20 border-amber-600/50'
|
||||||
|
: 'bg-gray-800/30 border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isUnlocked ? (
|
||||||
|
<CheckCircle className="w-4 h-4 text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<Trophy className="w-4 h-4 text-gray-500" />
|
||||||
|
)}
|
||||||
|
<span className={`text-sm font-semibold ${isUnlocked ? 'text-amber-300' : 'text-gray-300'}`}>
|
||||||
|
{achievement.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{achievement.reward.title && isUnlocked && (
|
||||||
|
<Badge className="text-xs bg-purple-900/50 text-purple-300">
|
||||||
|
Title
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-400 mb-2">
|
||||||
|
{achievement.desc}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isUnlocked && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Progress value={progressPercent} className="h-1 bg-gray-700" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>{progress.toLocaleString()} / {achievement.requirement.value.toLocaleString()}</span>
|
||||||
|
<span>{progressPercent.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUnlocked && achievement.reward && (
|
||||||
|
<div className="text-xs text-amber-400/70">
|
||||||
|
Reward:
|
||||||
|
{achievement.reward.insight && ` +${achievement.reward.insight} Insight`}
|
||||||
|
{achievement.reward.manaBonus && ` +${achievement.reward.manaBonus} Max Mana`}
|
||||||
|
{achievement.reward.damageBonus && ` +${(achievement.reward.damageBonus * 100).toFixed(0)}% Damage`}
|
||||||
|
{achievement.reward.title && ` "${achievement.reward.title}"`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/game/ActionButtons.tsx
Executable file
86
src/components/game/ActionButtons.tsx
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Sparkles, Swords, BookOpen, Target, FlaskConical } from 'lucide-react';
|
||||||
|
import type { GameAction } from '@/lib/game/types';
|
||||||
|
|
||||||
|
interface ActionButtonsProps {
|
||||||
|
currentAction: GameAction;
|
||||||
|
designProgress: { progress: number; required: number } | null;
|
||||||
|
preparationProgress: { progress: number; required: number } | null;
|
||||||
|
applicationProgress: { progress: number; required: number } | null;
|
||||||
|
setAction: (action: GameAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionButtons({
|
||||||
|
currentAction,
|
||||||
|
designProgress,
|
||||||
|
preparationProgress,
|
||||||
|
applicationProgress,
|
||||||
|
setAction,
|
||||||
|
}: ActionButtonsProps) {
|
||||||
|
const actions: { id: GameAction; label: string; icon: typeof Swords }[] = [
|
||||||
|
{ id: 'meditate', label: 'Meditate', icon: Sparkles },
|
||||||
|
{ id: 'climb', label: 'Climb', icon: Swords },
|
||||||
|
{ id: 'study', label: 'Study', icon: BookOpen },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasDesignProgress = designProgress !== null;
|
||||||
|
const hasPrepProgress = preparationProgress !== null;
|
||||||
|
const hasAppProgress = applicationProgress !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{actions.map(({ id, label, icon: Icon }) => (
|
||||||
|
<Button
|
||||||
|
key={id}
|
||||||
|
variant={currentAction === id ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={`h-9 ${currentAction === id ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||||
|
onClick={() => setAction(id)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 mr-1" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Crafting actions row - shown when there's active crafting progress */}
|
||||||
|
{(hasDesignProgress || hasPrepProgress || hasAppProgress) && (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Button
|
||||||
|
variant={currentAction === 'design' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasDesignProgress}
|
||||||
|
className={`h-9 ${currentAction === 'design' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||||
|
onClick={() => hasDesignProgress && setAction('design')}
|
||||||
|
>
|
||||||
|
<Target className="w-4 h-4 mr-1" />
|
||||||
|
Design
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentAction === 'prepare' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPrepProgress}
|
||||||
|
className={`h-9 ${currentAction === 'prepare' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||||
|
onClick={() => hasPrepProgress && setAction('prepare')}
|
||||||
|
>
|
||||||
|
<FlaskConical className="w-4 h-4 mr-1" />
|
||||||
|
Prepare
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentAction === 'enchant' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasAppProgress}
|
||||||
|
className={`h-9 ${currentAction === 'enchant' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||||
|
onClick={() => hasAppProgress && setAction('enchant')}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-1" />
|
||||||
|
Enchant
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/game/CalendarDisplay.tsx
Executable file
50
src/components/game/CalendarDisplay.tsx
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
|
||||||
|
|
||||||
|
interface CalendarDisplayProps {
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
incursionStrength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarDisplay({ day }: CalendarDisplayProps) {
|
||||||
|
const days: React.ReactElement[] = [];
|
||||||
|
|
||||||
|
for (let d = 1; d <= MAX_DAY; d++) {
|
||||||
|
let dayClass = 'w-6 h-6 sm:w-7 sm:h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
|
||||||
|
|
||||||
|
if (d < day) {
|
||||||
|
dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
|
||||||
|
} else if (d === day) {
|
||||||
|
dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30';
|
||||||
|
} else {
|
||||||
|
dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d >= INCURSION_START_DAY) {
|
||||||
|
dayClass += ' border-red-600/50';
|
||||||
|
}
|
||||||
|
|
||||||
|
days.push(
|
||||||
|
<Tooltip key={d}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className={dayClass}>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Day {d}</p>
|
||||||
|
{d >= INCURSION_START_DAY && <p className="text-red-400">Incursion Active</p>}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-7 sm:grid-cols-7 md:grid-cols-14 gap-1">
|
||||||
|
{days}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
src/components/game/ComboMeter.tsx
Executable file
143
src/components/game/ComboMeter.tsx
Executable file
@@ -0,0 +1,143 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Zap, Flame, Sparkles } from 'lucide-react';
|
||||||
|
import type { ComboState } from '@/lib/game/types';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
|
|
||||||
|
interface ComboMeterProps {
|
||||||
|
combo: ComboState;
|
||||||
|
isClimbing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComboMeter({ combo, isClimbing }: ComboMeterProps) {
|
||||||
|
const comboPercent = Math.min(100, combo.count);
|
||||||
|
const multiplierPercent = Math.min(100, ((combo.multiplier - 1) / 2) * 100); // Max 300% = 200% bonus
|
||||||
|
|
||||||
|
// Combo tier names
|
||||||
|
const getComboTier = (count: number): { name: string; color: string } => {
|
||||||
|
if (count >= 100) return { name: 'LEGENDARY', color: 'text-amber-400' };
|
||||||
|
if (count >= 75) return { name: 'Master', color: 'text-purple-400' };
|
||||||
|
if (count >= 50) return { name: 'Expert', color: 'text-blue-400' };
|
||||||
|
if (count >= 25) return { name: 'Adept', color: 'text-green-400' };
|
||||||
|
if (count >= 10) return { name: 'Novice', color: 'text-cyan-400' };
|
||||||
|
return { name: 'Building...', color: 'text-gray-400' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const tier = getComboTier(combo.count);
|
||||||
|
const hasElementChain = combo.elementChain.length === 3 && new Set(combo.elementChain).size === 3;
|
||||||
|
|
||||||
|
if (!isClimbing && combo.count === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
Combo Meter
|
||||||
|
{combo.count >= 10 && (
|
||||||
|
<Badge className={`ml-auto ${tier.color} bg-gray-800`}>
|
||||||
|
{tier.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Combo Count */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-gray-400">Hits</span>
|
||||||
|
<span className={`font-bold ${tier.color}`}>
|
||||||
|
{combo.count}
|
||||||
|
{combo.maxCombo > combo.count && (
|
||||||
|
<span className="text-gray-500 text-xs ml-2">max: {combo.maxCombo}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={comboPercent}
|
||||||
|
className="h-2 bg-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Multiplier */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-gray-400">Multiplier</span>
|
||||||
|
<span className="font-bold text-amber-400">
|
||||||
|
{combo.multiplier.toFixed(2)}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${multiplierPercent}%`,
|
||||||
|
background: `linear-gradient(90deg, #F59E0B, #EF4444)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Element Chain */}
|
||||||
|
{combo.elementChain.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-gray-400">Element Chain</span>
|
||||||
|
{hasElementChain && (
|
||||||
|
<span className="text-green-400 text-xs">+25% bonus!</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{combo.elementChain.map((elem, i) => {
|
||||||
|
const elemDef = ELEMENTS[elem];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-8 h-8 rounded border flex items-center justify-center text-xs"
|
||||||
|
style={{
|
||||||
|
borderColor: elemDef?.color || '#60A5FA',
|
||||||
|
backgroundColor: `${elemDef?.color}20`,
|
||||||
|
color: elemDef?.color || '#60A5FA',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{elemDef?.sym || '?'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Empty slots */}
|
||||||
|
{Array.from({ length: 3 - combo.elementChain.length }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={`empty-${i}`}
|
||||||
|
className="w-8 h-8 rounded border border-gray-700 bg-gray-800/50 flex items-center justify-center text-gray-600"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Decay Warning */}
|
||||||
|
{isClimbing && combo.count > 0 && combo.decayTimer <= 3 && (
|
||||||
|
<div className="text-xs text-red-400 flex items-center gap-1">
|
||||||
|
<Flame className="w-3 h-3" />
|
||||||
|
Combo decaying soon!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not climbing warning */}
|
||||||
|
{!isClimbing && combo.count > 0 && (
|
||||||
|
<div className="text-xs text-amber-400 flex items-center gap-1">
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
Resume climbing to maintain combo
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/components/game/CraftingProgress.tsx
Executable file
161
src/components/game/CraftingProgress.tsx
Executable file
@@ -0,0 +1,161 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Target, FlaskConical, Sparkles, Play, Pause, X } from 'lucide-react';
|
||||||
|
import { fmt } from '@/lib/game/store';
|
||||||
|
import { formatStudyTime } from '@/lib/game/formatting';
|
||||||
|
import type { EquipmentInstance, EnchantmentDesign } from '@/lib/game/types';
|
||||||
|
|
||||||
|
interface CraftingProgressProps {
|
||||||
|
designProgress: { designId: string; progress: number; required: number } | null;
|
||||||
|
preparationProgress: { equipmentInstanceId: string; progress: number; required: number; manaCostPaid: number } | null;
|
||||||
|
applicationProgress: { equipmentInstanceId: string; designId: string; progress: number; required: number; manaPerHour: number; paused: boolean } | null;
|
||||||
|
equipmentInstances: Record<string, EquipmentInstance>;
|
||||||
|
enchantmentDesigns: EnchantmentDesign[];
|
||||||
|
cancelDesign: () => void;
|
||||||
|
cancelPreparation: () => void;
|
||||||
|
pauseApplication: () => void;
|
||||||
|
resumeApplication: () => void;
|
||||||
|
cancelApplication: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CraftingProgress({
|
||||||
|
designProgress,
|
||||||
|
preparationProgress,
|
||||||
|
applicationProgress,
|
||||||
|
equipmentInstances,
|
||||||
|
enchantmentDesigns,
|
||||||
|
cancelDesign,
|
||||||
|
cancelPreparation,
|
||||||
|
pauseApplication,
|
||||||
|
resumeApplication,
|
||||||
|
cancelApplication,
|
||||||
|
}: CraftingProgressProps) {
|
||||||
|
const progressSections: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
// Design progress
|
||||||
|
if (designProgress) {
|
||||||
|
const progressPct = Math.min(100, (designProgress.progress / designProgress.required) * 100);
|
||||||
|
progressSections.push(
|
||||||
|
<div key="design" className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Target className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-sm font-semibold text-cyan-300">
|
||||||
|
Designing Enchantment
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={cancelDesign}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(designProgress.progress)} / {formatStudyTime(designProgress.required)}</span>
|
||||||
|
<span>Design Time</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preparation progress
|
||||||
|
if (preparationProgress) {
|
||||||
|
const progressPct = Math.min(100, (preparationProgress.progress / preparationProgress.required) * 100);
|
||||||
|
const instance = equipmentInstances[preparationProgress.equipmentInstanceId];
|
||||||
|
progressSections.push(
|
||||||
|
<div key="prepare" className="p-3 rounded border border-green-600/50 bg-green-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-sm font-semibold text-green-300">
|
||||||
|
Preparing {instance?.name || 'Equipment'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={cancelPreparation}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(preparationProgress.progress)} / {formatStudyTime(preparationProgress.required)}</span>
|
||||||
|
<span>Mana spent: {fmt(preparationProgress.manaCostPaid)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application progress
|
||||||
|
if (applicationProgress) {
|
||||||
|
const progressPct = Math.min(100, (applicationProgress.progress / applicationProgress.required) * 100);
|
||||||
|
const instance = equipmentInstances[applicationProgress.equipmentInstanceId];
|
||||||
|
const design = enchantmentDesigns.find(d => d.id === applicationProgress.designId);
|
||||||
|
progressSections.push(
|
||||||
|
<div key="enchant" className="p-3 rounded border border-amber-600/50 bg-amber-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="text-sm font-semibold text-amber-300">
|
||||||
|
Enchanting {instance?.name || 'Equipment'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{applicationProgress.paused ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-green-400 hover:text-green-300"
|
||||||
|
onClick={resumeApplication}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-yellow-400 hover:text-yellow-300"
|
||||||
|
onClick={pauseApplication}
|
||||||
|
>
|
||||||
|
<Pause className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={cancelApplication}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(applicationProgress.progress)} / {formatStudyTime(applicationProgress.required)}</span>
|
||||||
|
<span>Mana/hr: {fmt(applicationProgress.manaPerHour)}</span>
|
||||||
|
</div>
|
||||||
|
{design && (
|
||||||
|
<div className="text-xs text-amber-400/70 mt-1">
|
||||||
|
Applying: {design.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return progressSections.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{progressSections}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
405
src/components/game/GameContext.tsx
Executable file
405
src/components/game/GameContext.tsx
Executable file
@@ -0,0 +1,405 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||||
|
import { useSkillStore } from '@/lib/game/stores/skillStore';
|
||||||
|
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||||
|
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
||||||
|
import { useUIStore } from '@/lib/game/stores/uiStore';
|
||||||
|
import { useCombatStore } from '@/lib/game/stores/combatStore';
|
||||||
|
import { useGameStore, useGameLoop } from '@/lib/game/stores/gameStore';
|
||||||
|
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
|
||||||
|
import { getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||||
|
import { getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||||
|
import {
|
||||||
|
computeMaxMana,
|
||||||
|
computeRegen,
|
||||||
|
computeClickMana,
|
||||||
|
getMeditationBonus,
|
||||||
|
canAffordSpellCost,
|
||||||
|
calcDamage,
|
||||||
|
getFloorElement,
|
||||||
|
getBoonBonuses,
|
||||||
|
getIncursionStrength,
|
||||||
|
} from '@/lib/game/utils';
|
||||||
|
import {
|
||||||
|
ELEMENTS,
|
||||||
|
GUARDIANS,
|
||||||
|
SPELLS_DEF,
|
||||||
|
HOURS_PER_TICK,
|
||||||
|
TICK_MS,
|
||||||
|
} from '@/lib/game/constants';
|
||||||
|
import type { ElementDef, GuardianDef, SpellDef, GameAction } from '@/lib/game/types';
|
||||||
|
|
||||||
|
// Define a unified store type that combines all stores
|
||||||
|
interface UnifiedStore {
|
||||||
|
// From gameStore (coordinator)
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
incursionStrength: number;
|
||||||
|
containmentWards: number;
|
||||||
|
initialized: boolean;
|
||||||
|
tick: () => void;
|
||||||
|
resetGame: () => void;
|
||||||
|
gatherMana: () => void;
|
||||||
|
startNewLoop: () => void;
|
||||||
|
|
||||||
|
// From manaStore
|
||||||
|
rawMana: number;
|
||||||
|
meditateTicks: number;
|
||||||
|
totalManaGathered: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
setRawMana: (amount: number) => void;
|
||||||
|
addRawMana: (amount: number, max: number) => void;
|
||||||
|
spendRawMana: (amount: number) => boolean;
|
||||||
|
convertMana: (element: string, amount: number) => boolean;
|
||||||
|
unlockElement: (element: string, cost: number) => boolean;
|
||||||
|
craftComposite: (target: string, recipe: string[]) => boolean;
|
||||||
|
|
||||||
|
// From skillStore
|
||||||
|
skills: Record<string, number>;
|
||||||
|
skillProgress: Record<string, number>;
|
||||||
|
skillUpgrades: Record<string, string[]>;
|
||||||
|
skillTiers: Record<string, number>;
|
||||||
|
paidStudySkills: Record<string, number>;
|
||||||
|
currentStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
|
||||||
|
parallelStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
|
||||||
|
setSkillLevel: (skillId: string, level: number) => void;
|
||||||
|
startStudyingSkill: (skillId: string, rawMana: number) => { started: boolean; cost: number };
|
||||||
|
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => { started: boolean; cost: number };
|
||||||
|
cancelStudy: (retentionBonus: number) => void;
|
||||||
|
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||||
|
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||||
|
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
||||||
|
tierUpSkill: (skillId: string) => void;
|
||||||
|
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: Array<{ id: string; name: string; desc: string; milestone: 5 | 10; effect: { type: string; stat?: string; value?: number; specialId?: string } }>; selected: string[] };
|
||||||
|
|
||||||
|
// From prestigeStore
|
||||||
|
loopCount: number;
|
||||||
|
insight: number;
|
||||||
|
totalInsight: number;
|
||||||
|
loopInsight: number;
|
||||||
|
prestigeUpgrades: Record<string, number>;
|
||||||
|
memorySlots: number;
|
||||||
|
pactSlots: number;
|
||||||
|
memories: Array<{ skillId: string; level: number; tier: number; upgrades: string[] }>;
|
||||||
|
defeatedGuardians: number[];
|
||||||
|
signedPacts: number[];
|
||||||
|
pactRitualFloor: number | null;
|
||||||
|
pactRitualProgress: number;
|
||||||
|
doPrestige: (id: string) => void;
|
||||||
|
addMemory: (memory: { skillId: string; level: number; tier: number; upgrades: string[] }) => void;
|
||||||
|
removeMemory: (skillId: string) => void;
|
||||||
|
clearMemories: () => void;
|
||||||
|
startPactRitual: (floor: number, rawMana: number) => boolean;
|
||||||
|
cancelPactRitual: () => void;
|
||||||
|
removePact: (floor: number) => void;
|
||||||
|
defeatGuardian: (floor: number) => void;
|
||||||
|
|
||||||
|
// From combatStore
|
||||||
|
currentFloor: number;
|
||||||
|
floorHP: number;
|
||||||
|
floorMaxHP: number;
|
||||||
|
maxFloorReached: number;
|
||||||
|
activeSpell: string;
|
||||||
|
currentAction: GameAction;
|
||||||
|
castProgress: number;
|
||||||
|
spells: Record<string, { learned: boolean; level: number; studyProgress?: number }>;
|
||||||
|
setAction: (action: GameAction) => void;
|
||||||
|
setSpell: (spellId: string) => void;
|
||||||
|
learnSpell: (spellId: string) => void;
|
||||||
|
advanceFloor: () => void;
|
||||||
|
|
||||||
|
// From uiStore
|
||||||
|
log: string[];
|
||||||
|
paused: boolean;
|
||||||
|
gameOver: boolean;
|
||||||
|
victory: boolean;
|
||||||
|
addLog: (message: string) => void;
|
||||||
|
togglePause: () => void;
|
||||||
|
setPaused: (paused: boolean) => void;
|
||||||
|
setGameOver: (gameOver: boolean, victory?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameContextValue {
|
||||||
|
// Unified store for backward compatibility
|
||||||
|
store: UnifiedStore;
|
||||||
|
|
||||||
|
// Individual stores for direct access if needed
|
||||||
|
skillStore: ReturnType<typeof useSkillStore.getState>;
|
||||||
|
manaStore: ReturnType<typeof useManaStore.getState>;
|
||||||
|
prestigeStore: ReturnType<typeof usePrestigeStore.getState>;
|
||||||
|
uiStore: ReturnType<typeof useUIStore.getState>;
|
||||||
|
combatStore: ReturnType<typeof useCombatStore.getState>;
|
||||||
|
|
||||||
|
// Computed effects from upgrades
|
||||||
|
upgradeEffects: ReturnType<typeof computeEffects>;
|
||||||
|
|
||||||
|
// Derived stats
|
||||||
|
maxMana: number;
|
||||||
|
baseRegen: number;
|
||||||
|
clickMana: number;
|
||||||
|
floorElem: string;
|
||||||
|
floorElemDef: ElementDef | undefined;
|
||||||
|
isGuardianFloor: boolean;
|
||||||
|
currentGuardian: GuardianDef | undefined;
|
||||||
|
activeSpellDef: SpellDef | undefined;
|
||||||
|
meditationMultiplier: number;
|
||||||
|
incursionStrength: number;
|
||||||
|
studySpeedMult: number;
|
||||||
|
studyCostMult: number;
|
||||||
|
|
||||||
|
// Effective regen calculations
|
||||||
|
effectiveRegenWithSpecials: number;
|
||||||
|
manaCascadeBonus: number;
|
||||||
|
effectiveRegen: number;
|
||||||
|
|
||||||
|
// DPS calculation
|
||||||
|
dps: number;
|
||||||
|
|
||||||
|
// Boons
|
||||||
|
activeBoons: ReturnType<typeof getBoonBonuses>;
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
canCastSpell: (spellId: string) => boolean;
|
||||||
|
hasSpecial: (effects: ReturnType<typeof computeEffects>, specialId: string) => boolean;
|
||||||
|
SPECIAL_EFFECTS: typeof SPECIAL_EFFECTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameContext = createContext<GameContextValue | null>(null);
|
||||||
|
|
||||||
|
export function GameProvider({ children }: { children: ReactNode }) {
|
||||||
|
// Get all individual stores
|
||||||
|
const gameStore = useGameStore();
|
||||||
|
const skillState = useSkillStore();
|
||||||
|
const manaState = useManaStore();
|
||||||
|
const prestigeState = usePrestigeStore();
|
||||||
|
const uiState = useUIStore();
|
||||||
|
const combatState = useCombatStore();
|
||||||
|
|
||||||
|
// Create unified store object for backward compatibility
|
||||||
|
const unifiedStore = useMemo<UnifiedStore>(() => ({
|
||||||
|
// From gameStore
|
||||||
|
day: gameStore.day,
|
||||||
|
hour: gameStore.hour,
|
||||||
|
incursionStrength: gameStore.incursionStrength,
|
||||||
|
containmentWards: gameStore.containmentWards,
|
||||||
|
initialized: gameStore.initialized,
|
||||||
|
tick: gameStore.tick,
|
||||||
|
resetGame: gameStore.resetGame,
|
||||||
|
gatherMana: gameStore.gatherMana,
|
||||||
|
startNewLoop: gameStore.startNewLoop,
|
||||||
|
|
||||||
|
// From manaStore
|
||||||
|
rawMana: manaState.rawMana,
|
||||||
|
meditateTicks: manaState.meditateTicks,
|
||||||
|
totalManaGathered: manaState.totalManaGathered,
|
||||||
|
elements: manaState.elements,
|
||||||
|
setRawMana: manaState.setRawMana,
|
||||||
|
addRawMana: manaState.addRawMana,
|
||||||
|
spendRawMana: manaState.spendRawMana,
|
||||||
|
convertMana: manaState.convertMana,
|
||||||
|
unlockElement: manaState.unlockElement,
|
||||||
|
craftComposite: manaState.craftComposite,
|
||||||
|
|
||||||
|
// From skillStore
|
||||||
|
skills: skillState.skills,
|
||||||
|
skillProgress: skillState.skillProgress,
|
||||||
|
skillUpgrades: skillState.skillUpgrades,
|
||||||
|
skillTiers: skillState.skillTiers,
|
||||||
|
paidStudySkills: skillState.paidStudySkills,
|
||||||
|
currentStudyTarget: skillState.currentStudyTarget,
|
||||||
|
parallelStudyTarget: skillState.parallelStudyTarget,
|
||||||
|
setSkillLevel: skillState.setSkillLevel,
|
||||||
|
startStudyingSkill: skillState.startStudyingSkill,
|
||||||
|
startStudyingSpell: skillState.startStudyingSpell,
|
||||||
|
cancelStudy: skillState.cancelStudy,
|
||||||
|
selectSkillUpgrade: skillState.selectSkillUpgrade,
|
||||||
|
deselectSkillUpgrade: skillState.deselectSkillUpgrade,
|
||||||
|
commitSkillUpgrades: skillState.commitSkillUpgrades,
|
||||||
|
tierUpSkill: skillState.tierUpSkill,
|
||||||
|
getSkillUpgradeChoices: skillState.getSkillUpgradeChoices,
|
||||||
|
|
||||||
|
// From prestigeStore
|
||||||
|
loopCount: prestigeState.loopCount,
|
||||||
|
insight: prestigeState.insight,
|
||||||
|
totalInsight: prestigeState.totalInsight,
|
||||||
|
loopInsight: prestigeState.loopInsight,
|
||||||
|
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||||
|
memorySlots: prestigeState.memorySlots,
|
||||||
|
pactSlots: prestigeState.pactSlots,
|
||||||
|
memories: prestigeState.memories,
|
||||||
|
defeatedGuardians: prestigeState.defeatedGuardians,
|
||||||
|
signedPacts: prestigeState.signedPacts,
|
||||||
|
pactRitualFloor: prestigeState.pactRitualFloor,
|
||||||
|
pactRitualProgress: prestigeState.pactRitualProgress,
|
||||||
|
doPrestige: prestigeState.doPrestige,
|
||||||
|
addMemory: prestigeState.addMemory,
|
||||||
|
removeMemory: prestigeState.removeMemory,
|
||||||
|
clearMemories: prestigeState.clearMemories,
|
||||||
|
startPactRitual: prestigeState.startPactRitual,
|
||||||
|
cancelPactRitual: prestigeState.cancelPactRitual,
|
||||||
|
removePact: prestigeState.removePact,
|
||||||
|
defeatGuardian: prestigeState.defeatGuardian,
|
||||||
|
|
||||||
|
// From combatStore
|
||||||
|
currentFloor: combatState.currentFloor,
|
||||||
|
floorHP: combatState.floorHP,
|
||||||
|
floorMaxHP: combatState.floorMaxHP,
|
||||||
|
maxFloorReached: combatState.maxFloorReached,
|
||||||
|
activeSpell: combatState.activeSpell,
|
||||||
|
currentAction: combatState.currentAction,
|
||||||
|
castProgress: combatState.castProgress,
|
||||||
|
spells: combatState.spells,
|
||||||
|
setAction: combatState.setAction,
|
||||||
|
setSpell: combatState.setSpell,
|
||||||
|
learnSpell: combatState.learnSpell,
|
||||||
|
advanceFloor: combatState.advanceFloor,
|
||||||
|
|
||||||
|
// From uiStore
|
||||||
|
log: uiState.logs,
|
||||||
|
paused: uiState.paused,
|
||||||
|
gameOver: uiState.gameOver,
|
||||||
|
victory: uiState.victory,
|
||||||
|
addLog: uiState.addLog,
|
||||||
|
togglePause: uiState.togglePause,
|
||||||
|
setPaused: uiState.setPaused,
|
||||||
|
setGameOver: uiState.setGameOver,
|
||||||
|
}), [gameStore, skillState, manaState, prestigeState, uiState, combatState]);
|
||||||
|
|
||||||
|
// Computed effects from upgrades
|
||||||
|
const upgradeEffects = useMemo(
|
||||||
|
() => computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {}),
|
||||||
|
[skillState.skillUpgrades, skillState.skillTiers]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a minimal state object for compute functions
|
||||||
|
const stateForCompute = useMemo(() => ({
|
||||||
|
skills: skillState.skills,
|
||||||
|
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||||
|
skillUpgrades: skillState.skillUpgrades,
|
||||||
|
skillTiers: skillState.skillTiers,
|
||||||
|
signedPacts: prestigeState.signedPacts,
|
||||||
|
rawMana: manaState.rawMana,
|
||||||
|
meditateTicks: manaState.meditateTicks,
|
||||||
|
incursionStrength: gameStore.incursionStrength,
|
||||||
|
}), [skillState, prestigeState, manaState, gameStore.incursionStrength]);
|
||||||
|
|
||||||
|
// Derived stats
|
||||||
|
const maxMana = useMemo(
|
||||||
|
() => computeMaxMana(stateForCompute, upgradeEffects),
|
||||||
|
[stateForCompute, upgradeEffects]
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseRegen = useMemo(
|
||||||
|
() => computeRegen(stateForCompute, upgradeEffects),
|
||||||
|
[stateForCompute, upgradeEffects]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clickMana = useMemo(() => computeClickMana(stateForCompute), [stateForCompute]);
|
||||||
|
|
||||||
|
// Floor element from combat store
|
||||||
|
const floorElem = useMemo(() => getFloorElement(combatState.currentFloor), [combatState.currentFloor]);
|
||||||
|
const floorElemDef = ELEMENTS[floorElem];
|
||||||
|
const isGuardianFloor = !!GUARDIANS[combatState.currentFloor];
|
||||||
|
const currentGuardian = GUARDIANS[combatState.currentFloor];
|
||||||
|
const activeSpellDef = SPELLS_DEF[combatState.activeSpell];
|
||||||
|
|
||||||
|
const meditationMultiplier = useMemo(
|
||||||
|
() => getMeditationBonus(manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency),
|
||||||
|
[manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency]
|
||||||
|
);
|
||||||
|
|
||||||
|
const incursionStrength = useMemo(
|
||||||
|
() => getIncursionStrength(gameStore.day, gameStore.hour),
|
||||||
|
[gameStore.day, gameStore.hour]
|
||||||
|
);
|
||||||
|
|
||||||
|
const studySpeedMult = useMemo(
|
||||||
|
() => getStudySpeedMultiplier(skillState.skills),
|
||||||
|
[skillState.skills]
|
||||||
|
);
|
||||||
|
|
||||||
|
const studyCostMult = useMemo(
|
||||||
|
() => getStudyCostMultiplier(skillState.skills),
|
||||||
|
[skillState.skills]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Effective regen calculations
|
||||||
|
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
|
||||||
|
|
||||||
|
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
|
||||||
|
? Math.floor(maxMana / 100) * 0.1
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
|
||||||
|
|
||||||
|
// Active boons
|
||||||
|
const activeBoons = useMemo(
|
||||||
|
() => getBoonBonuses(prestigeState.signedPacts),
|
||||||
|
[prestigeState.signedPacts]
|
||||||
|
);
|
||||||
|
|
||||||
|
// DPS calculation - based on active spell, attack speed, and damage
|
||||||
|
const dps = useMemo(() => {
|
||||||
|
if (!activeSpellDef) return 0;
|
||||||
|
const baseDmg = calcDamage(
|
||||||
|
{ skills: skillState.skills, signedPacts: prestigeState.signedPacts },
|
||||||
|
combatState.activeSpell,
|
||||||
|
floorElem
|
||||||
|
);
|
||||||
|
const dmgWithEffects = baseDmg * upgradeEffects.baseDamageMultiplier + upgradeEffects.baseDamageBonus;
|
||||||
|
const attackSpeed = (1 + (skillState.skills.quickCast || 0) * 0.05) * upgradeEffects.attackSpeedMultiplier;
|
||||||
|
const castSpeed = activeSpellDef.castSpeed || 1;
|
||||||
|
return dmgWithEffects * attackSpeed * castSpeed;
|
||||||
|
}, [activeSpellDef, skillState.skills, prestigeState.signedPacts, floorElem, upgradeEffects, combatState.activeSpell]);
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const canCastSpell = (spellId: string): boolean => {
|
||||||
|
const spell = SPELLS_DEF[spellId];
|
||||||
|
if (!spell) return false;
|
||||||
|
return canAffordSpellCost(spell.cost, manaState.rawMana, manaState.elements);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: GameContextValue = {
|
||||||
|
store: unifiedStore,
|
||||||
|
skillStore: skillState,
|
||||||
|
manaStore: manaState,
|
||||||
|
prestigeStore: prestigeState,
|
||||||
|
uiStore: uiState,
|
||||||
|
combatStore: combatState,
|
||||||
|
upgradeEffects,
|
||||||
|
maxMana,
|
||||||
|
baseRegen,
|
||||||
|
clickMana,
|
||||||
|
floorElem,
|
||||||
|
floorElemDef,
|
||||||
|
isGuardianFloor,
|
||||||
|
currentGuardian,
|
||||||
|
activeSpellDef,
|
||||||
|
meditationMultiplier,
|
||||||
|
incursionStrength,
|
||||||
|
studySpeedMult,
|
||||||
|
studyCostMult,
|
||||||
|
effectiveRegenWithSpecials,
|
||||||
|
manaCascadeBonus,
|
||||||
|
effectiveRegen,
|
||||||
|
dps,
|
||||||
|
activeBoons,
|
||||||
|
canCastSpell,
|
||||||
|
hasSpecial,
|
||||||
|
SPECIAL_EFFECTS,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameContext() {
|
||||||
|
const context = useContext(GameContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useGameContext must be used within a GameProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export useGameLoop for convenience
|
||||||
|
export { useGameLoop };
|
||||||
193
src/components/game/GrimoireTab.tsx
Executable file
193
src/components/game/GrimoireTab.tsx
Executable file
@@ -0,0 +1,193 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useGameStore, fmt, fmtDec, computePactMultiplier } from '@/lib/game/store';
|
||||||
|
import { ELEMENTS, GUARDIANS, PRESTIGE_DEF } from '@/lib/game/constants';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
|
export function GrimoireTab() {
|
||||||
|
const store = useGameStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Current Status */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||||
|
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Current Insight</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Insight</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
|
||||||
|
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Signed Pacts */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Signed Pacts ({store.signedPacts.length}/{1 + (store.prestigeUpgrades.pactCapacity || 0)})</CardTitle>
|
||||||
|
{store.signedPacts.length > 1 && (
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
Combined: ×{fmtDec(computePactMultiplier(store), 2)} damage
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{store.signedPacts.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-sm">No pacts signed yet. Defeat guardians to earn pacts.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{store.signedPacts.map((floor) => {
|
||||||
|
const guardian = GUARDIANS[floor];
|
||||||
|
if (!guardian) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={floor}
|
||||||
|
className="p-3 rounded border"
|
||||||
|
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}10` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||||
|
{guardian.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">{guardian.theme} • Floor {floor}</div>
|
||||||
|
</div>
|
||||||
|
<Badge style={{ backgroundColor: `${guardian.color}30`, color: guardian.color }}>
|
||||||
|
{guardian.damageMultiplier}x dmg / {guardian.insightMultiplier}x insight
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unique Boon */}
|
||||||
|
{guardian.uniqueBoon && (
|
||||||
|
<div className="mb-2 p-2 bg-cyan-900/20 rounded border border-cyan-800/30">
|
||||||
|
<div className="text-xs font-semibold text-cyan-300">✨ {guardian.uniqueBoon.name}</div>
|
||||||
|
<div className="text-xs text-cyan-200/70">{guardian.uniqueBoon.desc}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Perks & Costs */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
{guardian.perks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-green-400 font-semibold mb-1">Perks</div>
|
||||||
|
{guardian.perks.map(perk => (
|
||||||
|
<div key={perk.id} className="text-green-300/70">• {perk.desc}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{guardian.costs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-red-400 font-semibold mb-1">Costs</div>
|
||||||
|
{guardian.costs.map(cost => (
|
||||||
|
<div key={cost.id} className="text-red-300/70">• {cost.desc}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unlocked Mana */}
|
||||||
|
{guardian.unlocksMana.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{guardian.unlocksMana.map(elemId => {
|
||||||
|
const elem = ELEMENTS[elemId];
|
||||||
|
return (
|
||||||
|
<Badge key={elemId} variant="outline" className="text-xs" style={{ borderColor: elem?.color, color: elem?.color }}>
|
||||||
|
{elem?.sym}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Prestige Upgrades */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Insight Upgrades (Permanent)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
|
||||||
|
const level = store.prestigeUpgrades[id] || 0;
|
||||||
|
const maxed = level >= def.max;
|
||||||
|
const canBuy = !maxed && store.insight >= def.cost;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="p-3 rounded border border-gray-700 bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{level}/{def.max}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={canBuy ? 'default' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canBuy}
|
||||||
|
onClick={() => store.doPrestige(id)}
|
||||||
|
>
|
||||||
|
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Game Button */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-400">Reset All Progress</div>
|
||||||
|
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
|
||||||
|
store.resetGame();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/components/game/LabTab.tsx
Executable file
171
src/components/game/LabTab.tsx
Executable file
@@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useGameStore } from '@/lib/game/store';
|
||||||
|
import { ELEMENTS, MANA_PER_ELEMENT } from '@/lib/game/constants';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function LabTab() {
|
||||||
|
const store = useGameStore();
|
||||||
|
const [convertTarget, setConvertTarget] = useState('fire');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Elemental Mana Display */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Elemental Mana</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
|
||||||
|
{Object.entries(store.elements)
|
||||||
|
.filter(([, state]) => state.unlocked && state.current >= 1)
|
||||||
|
.map(([id, state]) => {
|
||||||
|
const def = ELEMENTS[id];
|
||||||
|
const isSelected = convertTarget === id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={`p-2 rounded border cursor-pointer transition-all ${isSelected ? 'border-blue-500 bg-blue-900/20' : 'border-gray-700 bg-gray-800/50 hover:border-gray-600'}`}
|
||||||
|
style={{ borderColor: isSelected ? def?.color : undefined }}
|
||||||
|
onClick={() => setConvertTarget(id)}
|
||||||
|
>
|
||||||
|
<div className="text-lg text-center">{def?.sym}</div>
|
||||||
|
<div className="text-xs font-semibold text-center" style={{ color: def?.color }}>{def?.name}</div>
|
||||||
|
<div className="text-xs text-gray-400 game-mono text-center">{state.current}/{state.max}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Element Conversion */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Element Conversion</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-gray-400 mb-3">
|
||||||
|
Convert raw mana to elemental mana (100:1 ratio)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => store.convertMana(convertTarget, 1)}
|
||||||
|
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT}
|
||||||
|
>
|
||||||
|
+1 ({MANA_PER_ELEMENT})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => store.convertMana(convertTarget, 10)}
|
||||||
|
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 10}
|
||||||
|
>
|
||||||
|
+10 ({MANA_PER_ELEMENT * 10})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => store.convertMana(convertTarget, 100)}
|
||||||
|
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 100}
|
||||||
|
>
|
||||||
|
+100 ({MANA_PER_ELEMENT * 100})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Unlock Elements */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Unlock Elements</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-gray-400 mb-3">
|
||||||
|
Unlock new elemental affinities (500 mana each)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{Object.entries(store.elements)
|
||||||
|
.filter(([id, state]) => !state.unlocked && ELEMENTS[id]?.cat !== 'exotic')
|
||||||
|
.map(([id]) => {
|
||||||
|
const def = ELEMENTS[id];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="p-2 rounded border border-gray-700 bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div className="text-lg opacity-50">{def?.sym}</div>
|
||||||
|
<div className="text-xs font-semibold text-gray-500">{def?.name}</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-1 w-full"
|
||||||
|
disabled={store.rawMana < 500}
|
||||||
|
onClick={() => store.unlockElement(id)}
|
||||||
|
>
|
||||||
|
Unlock
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Composite Crafting */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Composite & Exotic Crafting</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||||
|
{Object.entries(ELEMENTS)
|
||||||
|
.filter(([, def]) => def.recipe)
|
||||||
|
.map(([id, def]) => {
|
||||||
|
const state = store.elements[id];
|
||||||
|
const recipe = def.recipe!;
|
||||||
|
const canCraft = recipe.every(
|
||||||
|
(r) => (store.elements[r]?.current || 0) >= recipe.filter((x) => x === r).length
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={`p-3 rounded border ${canCraft ? 'border-gray-600 bg-gray-800/50' : 'border-gray-700 bg-gray-800/30 opacity-50'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-2xl">{def.sym}</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold" style={{ color: def.color }}>
|
||||||
|
{def.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{def.cat}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mb-2">
|
||||||
|
{recipe.map((r) => ELEMENTS[r]?.sym).join(' + ')}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={canCraft ? 'default' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canCraft}
|
||||||
|
onClick={() => store.craftComposite(id)}
|
||||||
|
>
|
||||||
|
Craft
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
460
src/components/game/LootInventory.tsx
Executable file
460
src/components/game/LootInventory.tsx
Executable file
@@ -0,0 +1,460 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Gem, Sparkles, Scroll, Droplet, Trash2, Search,
|
||||||
|
Package, Sword, Shield, Shirt, Crown, ArrowUpDown,
|
||||||
|
Wrench, AlertTriangle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
|
||||||
|
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||||
|
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
|
interface LootInventoryProps {
|
||||||
|
inventory: LootInventoryType;
|
||||||
|
elements?: Record<string, ElementState>;
|
||||||
|
equipmentInstances?: Record<string, EquipmentInstance>;
|
||||||
|
onDeleteMaterial?: (materialId: string, amount: number) => void;
|
||||||
|
onDeleteEquipment?: (instanceId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortMode = 'name' | 'rarity' | 'count';
|
||||||
|
type FilterMode = 'all' | 'materials' | 'essence' | 'blueprints' | 'equipment';
|
||||||
|
|
||||||
|
const RARITY_ORDER = {
|
||||||
|
common: 0,
|
||||||
|
uncommon: 1,
|
||||||
|
rare: 2,
|
||||||
|
epic: 3,
|
||||||
|
legendary: 4,
|
||||||
|
mythic: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ICONS: Record<string, typeof Sword> = {
|
||||||
|
caster: Sword,
|
||||||
|
shield: Shield,
|
||||||
|
catalyst: Sparkles,
|
||||||
|
head: Crown,
|
||||||
|
body: Shirt,
|
||||||
|
hands: Wrench,
|
||||||
|
feet: Package,
|
||||||
|
accessory: Gem,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LootInventoryDisplay({
|
||||||
|
inventory,
|
||||||
|
elements,
|
||||||
|
equipmentInstances = {},
|
||||||
|
onDeleteMaterial,
|
||||||
|
onDeleteEquipment,
|
||||||
|
}: LootInventoryProps) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [sortMode, setSortMode] = useState<SortMode>('rarity');
|
||||||
|
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'material' | 'equipment'; id: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
// Count items
|
||||||
|
const materialCount = Object.values(inventory.materials).reduce((a, b) => a + b, 0);
|
||||||
|
const essenceCount = elements ? Object.values(elements).reduce((a, e) => a + e.current, 0) : 0;
|
||||||
|
const blueprintCount = inventory.blueprints.length;
|
||||||
|
const equipmentCount = Object.keys(equipmentInstances).length;
|
||||||
|
const totalItems = materialCount + blueprintCount + equipmentCount;
|
||||||
|
|
||||||
|
// Filter and sort materials
|
||||||
|
const filteredMaterials = Object.entries(inventory.materials)
|
||||||
|
.filter(([id, count]) => {
|
||||||
|
if (count <= 0) return false;
|
||||||
|
const drop = LOOT_DROPS[id];
|
||||||
|
if (!drop) return false;
|
||||||
|
if (searchTerm && !drop.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort(([aId, aCount], [bId, bCount]) => {
|
||||||
|
const aDrop = LOOT_DROPS[aId];
|
||||||
|
const bDrop = LOOT_DROPS[bId];
|
||||||
|
if (!aDrop || !bDrop) return 0;
|
||||||
|
|
||||||
|
switch (sortMode) {
|
||||||
|
case 'name':
|
||||||
|
return aDrop.name.localeCompare(bDrop.name);
|
||||||
|
case 'rarity':
|
||||||
|
return RARITY_ORDER[bDrop.rarity] - RARITY_ORDER[aDrop.rarity];
|
||||||
|
case 'count':
|
||||||
|
return bCount - aCount;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter and sort essence
|
||||||
|
const filteredEssence = elements
|
||||||
|
? Object.entries(elements)
|
||||||
|
.filter(([id, state]) => {
|
||||||
|
if (!state.unlocked || state.current <= 0) return false;
|
||||||
|
if (searchTerm && !ELEMENTS[id]?.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort(([aId, aState], [bId, bState]) => {
|
||||||
|
switch (sortMode) {
|
||||||
|
case 'name':
|
||||||
|
return (ELEMENTS[aId]?.name || aId).localeCompare(ELEMENTS[bId]?.name || bId);
|
||||||
|
case 'count':
|
||||||
|
return bState.current - aState.current;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Filter and sort equipment
|
||||||
|
const filteredEquipment = Object.entries(equipmentInstances)
|
||||||
|
.filter(([id, instance]) => {
|
||||||
|
if (searchTerm && !instance.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort(([aId, aInst], [bId, bInst]) => {
|
||||||
|
switch (sortMode) {
|
||||||
|
case 'name':
|
||||||
|
return aInst.name.localeCompare(bInst.name);
|
||||||
|
case 'rarity':
|
||||||
|
return RARITY_ORDER[bInst.rarity] - RARITY_ORDER[aInst.rarity];
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if we have anything to show
|
||||||
|
const hasItems = totalItems > 0 || essenceCount > 0;
|
||||||
|
|
||||||
|
if (!hasItems) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Gem className="w-4 h-4" />
|
||||||
|
Inventory
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-gray-500 text-sm text-center py-4">
|
||||||
|
No items collected yet. Defeat floors and guardians to find loot!
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteMaterial = (materialId: string) => {
|
||||||
|
const drop = LOOT_DROPS[materialId];
|
||||||
|
if (drop) {
|
||||||
|
setDeleteConfirm({ type: 'material', id: materialId, name: drop.name });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEquipment = (instanceId: string) => {
|
||||||
|
const instance = equipmentInstances[instanceId];
|
||||||
|
if (instance) {
|
||||||
|
setDeleteConfirm({ type: 'equipment', id: instanceId, name: instance.name });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (!deleteConfirm) return;
|
||||||
|
|
||||||
|
if (deleteConfirm.type === 'material' && onDeleteMaterial) {
|
||||||
|
onDeleteMaterial(deleteConfirm.id, inventory.materials[deleteConfirm.id] || 0);
|
||||||
|
} else if (deleteConfirm.type === 'equipment' && onDeleteEquipment) {
|
||||||
|
onDeleteEquipment(deleteConfirm.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Gem className="w-4 h-4" />
|
||||||
|
Inventory
|
||||||
|
<Badge className="ml-auto bg-gray-800 text-gray-300 text-xs">
|
||||||
|
{totalItems} items
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Search and Filter Controls */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="h-7 pl-7 bg-gray-800/50 border-gray-700 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 bg-gray-800/50"
|
||||||
|
onClick={() => setSortMode(sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity')}
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Tabs */}
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{[
|
||||||
|
{ mode: 'all' as FilterMode, label: 'All' },
|
||||||
|
{ mode: 'materials' as FilterMode, label: `Materials (${materialCount})` },
|
||||||
|
{ mode: 'essence' as FilterMode, label: `Essence (${essenceCount})` },
|
||||||
|
{ mode: 'blueprints' as FilterMode, label: `Blueprints (${blueprintCount})` },
|
||||||
|
{ mode: 'equipment' as FilterMode, label: `Equipment (${equipmentCount})` },
|
||||||
|
].map(({ mode, label }) => (
|
||||||
|
<Button
|
||||||
|
key={mode}
|
||||||
|
variant={filterMode === mode ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={`h-6 px-2 text-xs ${filterMode === mode ? 'bg-amber-600 hover:bg-amber-700' : 'bg-gray-800/50'}`}
|
||||||
|
onClick={() => setFilterMode(mode)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Materials */}
|
||||||
|
{(filterMode === 'all' || filterMode === 'materials') && filteredMaterials.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
Materials
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{filteredMaterials.map(([id, count]) => {
|
||||||
|
const drop = LOOT_DROPS[id];
|
||||||
|
if (!drop) return null;
|
||||||
|
const rarityStyle = RARITY_COLORS[drop.rarity];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="p-2 rounded border bg-gray-800/50 group relative"
|
||||||
|
style={{
|
||||||
|
borderColor: rarityStyle?.color || '#9CA3AF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
|
||||||
|
{drop.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
x{count}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 capitalize">
|
||||||
|
{drop.rarity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onDeleteMaterial && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||||
|
onClick={() => handleDeleteMaterial(id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Essence */}
|
||||||
|
{(filterMode === 'all' || filterMode === 'essence') && filteredEssence.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
|
||||||
|
<Droplet className="w-3 h-3" />
|
||||||
|
Elemental Essence
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{filteredEssence.map(([id, state]) => {
|
||||||
|
const elem = ELEMENTS[id];
|
||||||
|
if (!elem) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="p-2 rounded border bg-gray-800/50"
|
||||||
|
style={{
|
||||||
|
borderColor: elem.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span style={{ color: elem.color }}>{elem.sym}</span>
|
||||||
|
<span className="text-xs font-semibold" style={{ color: elem.color }}>
|
||||||
|
{elem.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{state.current} / {state.max}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Blueprints */}
|
||||||
|
{(filterMode === 'all' || filterMode === 'blueprints') && inventory.blueprints.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
|
||||||
|
<Scroll className="w-3 h-3" />
|
||||||
|
Blueprints (permanent)
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{inventory.blueprints.map((id) => {
|
||||||
|
const drop = LOOT_DROPS[id];
|
||||||
|
if (!drop) return null;
|
||||||
|
const rarityStyle = RARITY_COLORS[drop.rarity];
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={id}
|
||||||
|
className="text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${rarityStyle?.color}20`,
|
||||||
|
color: rarityStyle?.color,
|
||||||
|
borderColor: rarityStyle?.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{drop.name}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1 italic">
|
||||||
|
Blueprints are permanent unlocks - use them to craft equipment
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Equipment */}
|
||||||
|
{(filterMode === 'all' || filterMode === 'equipment') && filteredEquipment.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
|
||||||
|
<Package className="w-3 h-3" />
|
||||||
|
Equipment
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredEquipment.map(([id, instance]) => {
|
||||||
|
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||||
|
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||||
|
const rarityStyle = RARITY_COLORS[instance.rarity];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="p-2 rounded border bg-gray-800/50 group"
|
||||||
|
style={{
|
||||||
|
borderColor: rarityStyle?.color || '#9CA3AF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Icon className="w-4 h-4 mt-0.5" style={{ color: rarityStyle?.color }} />
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
|
||||||
|
{instance.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{type?.name} • {instance.usedCapacity}/{instance.totalCapacity} cap
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 capitalize">
|
||||||
|
{instance.rarity} • {instance.enchantments.length} enchants
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onDeleteEquipment && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||||
|
onClick={() => handleDeleteEquipment(id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
|
||||||
|
<AlertDialogContent className="bg-gray-900 border-gray-700">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-amber-400 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Delete Item
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-gray-300">
|
||||||
|
Are you sure you want to delete <strong>{deleteConfirm?.name}</strong>?
|
||||||
|
{deleteConfirm?.type === 'material' && (
|
||||||
|
<span className="block mt-2 text-red-400">
|
||||||
|
This will delete ALL {inventory.materials[deleteConfirm?.id || ''] || 0} of this material!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deleteConfirm?.type === 'equipment' && (
|
||||||
|
<span className="block mt-2 text-red-400">
|
||||||
|
This equipment and all its enchantments will be permanently lost!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="bg-gray-800 border-gray-700">Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/game/ManaDisplay.tsx
Executable file
123
src/components/game/ManaDisplay.tsx
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Zap, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { fmt, fmtDec } from '@/lib/game/store';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface ManaDisplayProps {
|
||||||
|
rawMana: number;
|
||||||
|
maxMana: number;
|
||||||
|
effectiveRegen: number;
|
||||||
|
meditationMultiplier: number;
|
||||||
|
clickMana: number;
|
||||||
|
isGathering: boolean;
|
||||||
|
onGatherStart: () => void;
|
||||||
|
onGatherEnd: () => void;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManaDisplay({
|
||||||
|
rawMana,
|
||||||
|
maxMana,
|
||||||
|
effectiveRegen,
|
||||||
|
meditationMultiplier,
|
||||||
|
clickMana,
|
||||||
|
isGathering,
|
||||||
|
onGatherStart,
|
||||||
|
onGatherEnd,
|
||||||
|
elements,
|
||||||
|
}: ManaDisplayProps) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
|
// Get unlocked elements with current > 0, sorted by current amount
|
||||||
|
const unlockedElements = Object.entries(elements)
|
||||||
|
.filter(([, state]) => state.unlocked && state.current > 0)
|
||||||
|
.sort((a, b) => b[1].current - a[1].current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
{/* Raw Mana - Main Display */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-3xl font-bold game-mono text-blue-400">{fmt(rawMana)}</span>
|
||||||
|
<span className="text-sm text-gray-400">/ {fmt(maxMana)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
+{fmtDec(effectiveRegen)} mana/hr {meditationMultiplier > 1.01 && <span className="text-purple-400">({fmtDec(meditationMultiplier, 1)}x med)</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
value={(rawMana / maxMana) * 100}
|
||||||
|
className="h-2 bg-gray-800"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className={`w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 ${isGathering ? 'animate-pulse' : ''}`}
|
||||||
|
onMouseDown={onGatherStart}
|
||||||
|
onMouseUp={onGatherEnd}
|
||||||
|
onMouseLeave={onGatherEnd}
|
||||||
|
onTouchStart={onGatherStart}
|
||||||
|
onTouchEnd={onGatherEnd}
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4 mr-2" />
|
||||||
|
Gather +{clickMana} Mana
|
||||||
|
{isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Elemental Mana Pools */}
|
||||||
|
{unlockedElements.length > 0 && (
|
||||||
|
<div className="border-t border-gray-700 pt-3 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center justify-between w-full text-xs text-gray-400 hover:text-gray-300 mb-2"
|
||||||
|
>
|
||||||
|
<span>Elemental Mana ({unlockedElements.length})</span>
|
||||||
|
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{unlockedElements.map(([id, state]) => {
|
||||||
|
const elem = ELEMENTS[id];
|
||||||
|
if (!elem) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="p-2 rounded bg-gray-800/50 border border-gray-700"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<span style={{ color: elem.color }}>{elem.sym}</span>
|
||||||
|
<span className="text-xs font-medium" style={{ color: elem.color }}>
|
||||||
|
{elem.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
|
||||||
|
backgroundColor: elem.color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 game-mono">
|
||||||
|
{fmt(state.current)}/{fmt(state.max)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
418
src/components/game/SkillsTab.tsx
Executable file
418
src/components/game/SkillsTab.tsx
Executable file
@@ -0,0 +1,418 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useGameStore, fmt, fmtDec } from '@/lib/game/store';
|
||||||
|
import { SKILLS_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||||
|
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||||
|
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
|
||||||
|
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { BookOpen, X } from 'lucide-react';
|
||||||
|
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||||
|
|
||||||
|
// Format study time
|
||||||
|
function formatStudyTime(hours: number): string {
|
||||||
|
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||||
|
return `${hours.toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillsTab() {
|
||||||
|
const store = useGameStore();
|
||||||
|
const { studySpeedMult, studyCostMult, hasParallelStudy } = useStudyStats();
|
||||||
|
|
||||||
|
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
|
||||||
|
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
|
||||||
|
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const upgradeEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
|
||||||
|
|
||||||
|
// Check if skill has milestone available
|
||||||
|
const hasMilestoneUpgrade = (skillId: string, level: number): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null => {
|
||||||
|
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||||
|
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||||
|
if (!path) return null;
|
||||||
|
|
||||||
|
if (level >= 5) {
|
||||||
|
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, store.skillTiers);
|
||||||
|
const selected5 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l5'));
|
||||||
|
if (upgrades5.length > 0 && selected5.length < 2) {
|
||||||
|
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level >= 10) {
|
||||||
|
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, store.skillTiers);
|
||||||
|
const selected10 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l10'));
|
||||||
|
if (upgrades10.length > 0 && selected10.length < 2) {
|
||||||
|
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render upgrade selection dialog
|
||||||
|
const renderUpgradeDialog = () => {
|
||||||
|
if (!upgradeDialogSkill) return null;
|
||||||
|
|
||||||
|
const skillDef = SKILLS_DEF[upgradeDialogSkill];
|
||||||
|
const level = store.skills[upgradeDialogSkill] || 0;
|
||||||
|
const { available, selected: alreadySelected } = store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
|
||||||
|
|
||||||
|
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
|
||||||
|
|
||||||
|
const toggleUpgrade = (upgradeId: string) => {
|
||||||
|
if (currentSelections.includes(upgradeId)) {
|
||||||
|
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
|
||||||
|
} else if (currentSelections.length < 2) {
|
||||||
|
setPendingUpgradeSelections([...currentSelections, upgradeId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
if (currentSelections.length === 2 && upgradeDialogSkill) {
|
||||||
|
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections, upgradeDialogMilestone);
|
||||||
|
}
|
||||||
|
setPendingUpgradeSelections([]);
|
||||||
|
setUpgradeDialogSkill(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setPendingUpgradeSelections([]);
|
||||||
|
setUpgradeDialogSkill(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!upgradeDialogSkill} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setPendingUpgradeSelections([]);
|
||||||
|
setUpgradeDialogSkill(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-amber-400">
|
||||||
|
Choose Upgrade - {skillDef?.name || upgradeDialogSkill}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
Level {upgradeDialogMilestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
{available.map((upgrade) => {
|
||||||
|
const isSelected = currentSelections.includes(upgrade.id);
|
||||||
|
const canToggle = currentSelections.length < 2 || isSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={upgrade.id}
|
||||||
|
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-amber-500 bg-amber-900/30'
|
||||||
|
: canToggle
|
||||||
|
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||||
|
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (canToggle) {
|
||||||
|
toggleUpgrade(upgrade.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
|
||||||
|
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||||
|
{upgrade.effect.type === 'multiplier' && (
|
||||||
|
<div className="text-xs text-green-400 mt-1">
|
||||||
|
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgrade.effect.type === 'bonus' && (
|
||||||
|
<div className="text-xs text-blue-400 mt-1">
|
||||||
|
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgrade.effect.type === 'special' && (
|
||||||
|
<div className="text-xs text-cyan-400 mt-1">
|
||||||
|
⚡ {upgrade.effect.specialDesc || 'Special effect'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleDone}
|
||||||
|
disabled={currentSelections.length !== 2}
|
||||||
|
>
|
||||||
|
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render study progress
|
||||||
|
const renderStudyProgress = () => {
|
||||||
|
if (!store.currentStudyTarget) return null;
|
||||||
|
|
||||||
|
const target = store.currentStudyTarget;
|
||||||
|
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||||
|
const def = SKILLS_DEF[target.id] || SKILLS_DEF[target.id.split('_t')[0]];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-sm font-semibold text-purple-300">
|
||||||
|
{def?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={() => store.cancelStudy()}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
|
||||||
|
<span>{studySpeedMult.toFixed(1)}x speed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Upgrade Selection Dialog */}
|
||||||
|
{renderUpgradeDialog()}
|
||||||
|
|
||||||
|
{/* Current Study Progress */}
|
||||||
|
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
|
||||||
|
<Card className="bg-gray-900/80 border-purple-600/50">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{renderStudyProgress()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{SKILL_CATEGORIES.map((cat) => {
|
||||||
|
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
|
||||||
|
if (skillsInCat.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||||
|
{cat.icon} {cat.name}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{skillsInCat.map(([id, def]) => {
|
||||||
|
const currentTier = store.skillTiers?.[id] || 1;
|
||||||
|
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
|
||||||
|
const tierMultiplier = getTierMultiplier(tieredSkillId);
|
||||||
|
|
||||||
|
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
|
||||||
|
const maxed = level >= def.max;
|
||||||
|
|
||||||
|
const isStudying = (store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
|
||||||
|
const savedProgress = store.skillProgress[tieredSkillId] || store.skillProgress[id] || 0;
|
||||||
|
|
||||||
|
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find(t => t.tier === currentTier);
|
||||||
|
const skillDisplayName = tierDef?.name || def.name;
|
||||||
|
|
||||||
|
// Check prerequisites
|
||||||
|
let prereqMet = true;
|
||||||
|
if (def.req) {
|
||||||
|
for (const [r, rl] of Object.entries(def.req)) {
|
||||||
|
if ((store.skills[r] || 0) < rl) {
|
||||||
|
prereqMet = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply skill modifiers
|
||||||
|
const studyEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
|
||||||
|
const effectiveSpeedMult = studySpeedMult * studyEffects.studySpeedMultiplier;
|
||||||
|
|
||||||
|
const tierStudyTime = def.studyTime * currentTier;
|
||||||
|
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
|
||||||
|
|
||||||
|
const baseCost = def.base * (level + 1) * currentTier;
|
||||||
|
const cost = Math.floor(baseCost * studyCostMult);
|
||||||
|
|
||||||
|
const canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
|
||||||
|
|
||||||
|
const milestoneInfo = hasMilestoneUpgrade(tieredSkillId, level);
|
||||||
|
const nextTierSkill = getNextTierSkill(tieredSkillId);
|
||||||
|
const canTierUp = maxed && nextTierSkill;
|
||||||
|
|
||||||
|
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
|
||||||
|
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
|
||||||
|
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
|
||||||
|
isStudying ? 'border-purple-500 bg-purple-900/20' :
|
||||||
|
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
|
||||||
|
'border-gray-700 bg-gray-800/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-sm">{skillDisplayName}</span>
|
||||||
|
{currentTier > 1 && (
|
||||||
|
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
|
||||||
|
)}
|
||||||
|
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
|
||||||
|
{selectedUpgrades.length > 0 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{selectedL5.length > 0 && (
|
||||||
|
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
|
||||||
|
)}
|
||||||
|
{selectedL10.length > 0 && (
|
||||||
|
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
|
||||||
|
{!prereqMet && def.req && (
|
||||||
|
<div className="text-xs text-red-400 mt-1">
|
||||||
|
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
|
||||||
|
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
|
||||||
|
</span>
|
||||||
|
{' • '}
|
||||||
|
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
|
||||||
|
Cost: {fmt(cost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{milestoneInfo && (
|
||||||
|
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
|
||||||
|
⭐ Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
|
||||||
|
{/* Level dots */}
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
{Array.from({ length: def.max }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`w-2 h-2 rounded-full border ${
|
||||||
|
i < level ? 'bg-purple-500 border-purple-400' :
|
||||||
|
i === 4 || i === 9 ? 'border-amber-500' :
|
||||||
|
'border-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isStudying ? (
|
||||||
|
<div className="text-xs text-purple-400">
|
||||||
|
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
|
||||||
|
</div>
|
||||||
|
) : milestoneInfo ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
|
onClick={() => {
|
||||||
|
setUpgradeDialogSkill(tieredSkillId);
|
||||||
|
setUpgradeDialogMilestone(milestoneInfo.milestone);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Choose Upgrades
|
||||||
|
</Button>
|
||||||
|
) : canTierUp ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-purple-600 hover:bg-purple-700"
|
||||||
|
onClick={() => store.tierUpSkill(tieredSkillId)}
|
||||||
|
>
|
||||||
|
⬆️ Tier Up
|
||||||
|
</Button>
|
||||||
|
) : maxed ? (
|
||||||
|
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={canStudy ? 'default' : 'outline'}
|
||||||
|
disabled={!canStudy}
|
||||||
|
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||||||
|
onClick={() => store.startStudyingSkill(tieredSkillId)}
|
||||||
|
>
|
||||||
|
Study ({fmt(cost)})
|
||||||
|
</Button>
|
||||||
|
{/* Parallel Study button */}
|
||||||
|
{hasParallelStudy &&
|
||||||
|
store.currentStudyTarget &&
|
||||||
|
!store.parallelStudyTarget &&
|
||||||
|
store.currentStudyTarget.id !== tieredSkillId &&
|
||||||
|
canStudy && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
|
||||||
|
onClick={() => store.startParallelStudySkill(tieredSkillId)}
|
||||||
|
>
|
||||||
|
⚡
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Study in parallel (50% speed)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/components/game/SpellsTab.tsx
Executable file
166
src/components/game/SpellsTab.tsx
Executable file
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage } from '@/lib/game/store';
|
||||||
|
import { ELEMENTS, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||||
|
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
|
||||||
|
// Format spell cost for display
|
||||||
|
function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
||||||
|
if (cost.type === 'raw') {
|
||||||
|
return `${cost.amount} raw`;
|
||||||
|
}
|
||||||
|
const elemDef = ELEMENTS[cost.element || ''];
|
||||||
|
return `${cost.amount} ${elemDef?.sym || '?'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cost color
|
||||||
|
function getSpellCostColor(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
||||||
|
if (cost.type === 'raw') {
|
||||||
|
return '#60A5FA';
|
||||||
|
}
|
||||||
|
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format study time
|
||||||
|
function formatStudyTime(hours: number): string {
|
||||||
|
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||||
|
return `${hours.toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpellsTab() {
|
||||||
|
const store = useGameStore();
|
||||||
|
const { studySpeedMult, studyCostMult } = useStudyStats();
|
||||||
|
|
||||||
|
const spellTiers = [0, 1, 2, 3, 4];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{spellTiers.map(tier => {
|
||||||
|
const spellsInTier = Object.entries(SPELLS_DEF).filter(([, def]) => def.tier === tier);
|
||||||
|
if (spellsInTier.length === 0) return null;
|
||||||
|
|
||||||
|
const tierNames = ['Basic Spells (Raw Mana)', 'Tier 1 - Elemental', 'Tier 2 - Advanced', 'Tier 3 - Master', 'Tier 4 - Legendary'];
|
||||||
|
const tierColors = ['text-gray-400', 'text-green-400', 'text-blue-400', 'text-purple-400', 'text-amber-400'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tier}>
|
||||||
|
<h3 className={`text-lg font-semibold mb-3 ${tierColors[tier]}`}>{tierNames[tier]}</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{spellsInTier.map(([id, def]) => {
|
||||||
|
const state = store.spells[id];
|
||||||
|
const learned = state?.learned;
|
||||||
|
const isStudying = store.currentStudyTarget?.id === id;
|
||||||
|
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||||
|
const baseStudyTime = def.studyTime || (def.tier * 4);
|
||||||
|
const isActive = store.activeSpell === id;
|
||||||
|
const canCast = learned && canAffordSpellCost(def.cost, store.rawMana, store.elements);
|
||||||
|
|
||||||
|
// Apply skill modifiers
|
||||||
|
const studyTime = baseStudyTime / studySpeedMult;
|
||||||
|
const unlockCost = Math.floor(def.unlock * studyCostMult);
|
||||||
|
|
||||||
|
// Can start studying?
|
||||||
|
const canStudy = !learned && !isStudying && store.rawMana >= unlockCost;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={id}
|
||||||
|
className={`bg-gray-900/80 border-gray-700 ${learned ? '' : 'opacity-75'} ${isStudying ? 'border-purple-500' : ''} ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm game-panel-title" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||||
|
{def.name}
|
||||||
|
</CardTitle>
|
||||||
|
{def.tier > 0 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
T{def.tier}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
|
||||||
|
<span className="mr-2">⚔️ {def.dmg} dmg</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost display */}
|
||||||
|
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||||
|
Cost: {formatSpellCost(def.cost)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{def.desc && (
|
||||||
|
<div className="text-xs text-gray-500 italic">{def.desc}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{def.effects && def.effects.length > 0 && (
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{def.effects.map((eff, i) => (
|
||||||
|
<Badge key={i} variant="outline" className="text-xs">
|
||||||
|
{eff.type === 'burn' && `🔥 Burn`}
|
||||||
|
{eff.type === 'stun' && `⚡ Stun`}
|
||||||
|
{eff.type === 'pierce' && `🎯 Pierce`}
|
||||||
|
{eff.type === 'multicast' && `✨ Multicast`}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{learned ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge className="bg-green-900/50 text-green-300">Learned</Badge>
|
||||||
|
{isActive && <Badge className="bg-amber-900/50 text-amber-300">Active</Badge>}
|
||||||
|
{!isActive && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => store.setSpell(id)}>
|
||||||
|
Set Active
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : isStudying ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Progress
|
||||||
|
value={Math.min(100, ((state?.studyProgress || 0) / studyTime) * 100)}
|
||||||
|
className="h-2 bg-gray-800"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-purple-400">
|
||||||
|
Studying... {formatStudyTime(state?.studyProgress || 0)}/{formatStudyTime(studyTime)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
<span className={studySpeedMult > 1 ? 'text-green-400' : ''}>
|
||||||
|
Study: {formatStudyTime(studyTime)}{studySpeedMult > 1 && <span className="text-xs ml-1">({Math.round(studySpeedMult * 100)}% speed)</span>}
|
||||||
|
</span>
|
||||||
|
{' • '}
|
||||||
|
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
|
||||||
|
Cost: {fmt(unlockCost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={canStudy ? 'default' : 'outline'}
|
||||||
|
disabled={!canStudy}
|
||||||
|
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||||||
|
onClick={() => store.startStudyingSpell(id)}
|
||||||
|
>
|
||||||
|
Start Study ({fmt(unlockCost)} mana)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
src/components/game/SpireTab.tsx
Executable file
320
src/components/game/SpireTab.tsx
Executable file
@@ -0,0 +1,320 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage, computePactMultiplier } from '@/lib/game/store';
|
||||||
|
import { ELEMENTS, GUARDIANS, SPELLS_DEF, MANA_PER_ELEMENT } from '@/lib/game/constants';
|
||||||
|
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||||
|
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { X, BookOpen } from 'lucide-react';
|
||||||
|
|
||||||
|
export function SpireTab() {
|
||||||
|
const store = useGameStore();
|
||||||
|
const { effectiveRegen, meditationMultiplier, incursionStrength } = useManaStats();
|
||||||
|
const {
|
||||||
|
floorElem, floorElemDef, isGuardianFloor, currentGuardian,
|
||||||
|
activeSpellDef, dps, damageBreakdown
|
||||||
|
} = useCombatStats();
|
||||||
|
const { effectiveStudySpeedMult } = useStudyStats();
|
||||||
|
|
||||||
|
// Check if spell can be cast
|
||||||
|
const canCastSpell = (spellId: string): boolean => {
|
||||||
|
const spell = SPELLS_DEF[spellId];
|
||||||
|
if (!spell) return false;
|
||||||
|
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render study progress
|
||||||
|
const renderStudyProgress = () => {
|
||||||
|
if (!store.currentStudyTarget) return null;
|
||||||
|
|
||||||
|
const target = store.currentStudyTarget;
|
||||||
|
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||||
|
const isSkill = target.type === 'skill';
|
||||||
|
const def = isSkill ? SPELLS_DEF[target.id] : SPELLS_DEF[target.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-sm font-semibold text-purple-300">
|
||||||
|
{def?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={() => store.cancelStudy()}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
|
||||||
|
<span>{effectiveStudySpeedMult.toFixed(1)}x speed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Current Floor Card */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
|
||||||
|
{store.currentFloor}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-sm">/ 100</span>
|
||||||
|
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
|
||||||
|
{floorElemDef?.sym} {floorElemDef?.name}
|
||||||
|
</span>
|
||||||
|
{isGuardianFloor && (
|
||||||
|
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isGuardianFloor && currentGuardian && (
|
||||||
|
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
|
||||||
|
⚔️ {currentGuardian.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HP Bar */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
|
||||||
|
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
|
||||||
|
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 game-mono">
|
||||||
|
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
|
||||||
|
<span>DPS: {store.currentAction === 'climb' && canCastSpell(store.activeSpell) ? fmtDec(dps) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong> •
|
||||||
|
Pacts: <strong className="text-amber-400">{store.signedPacts.length}</strong>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Spell Card */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Spell</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{activeSpellDef ? (
|
||||||
|
<>
|
||||||
|
<div className="text-lg font-semibold game-panel-title" style={{ color: activeSpellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[activeSpellDef.elem]?.color }}>
|
||||||
|
{activeSpellDef.name}
|
||||||
|
{activeSpellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200">Basic</Badge>}
|
||||||
|
{activeSpellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100">Legendary</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400 game-mono">
|
||||||
|
⚔️ {fmt(calcDamage(store, store.activeSpell))} dmg •
|
||||||
|
<span style={{ color: getSpellCostColor(activeSpellDef.cost) }}>
|
||||||
|
{' '}{formatSpellCost(activeSpellDef.cost)}
|
||||||
|
</span>
|
||||||
|
{' '}• ⚡ {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cast progress bar when climbing */}
|
||||||
|
{store.currentAction === 'climb' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>Cast Progress</span>
|
||||||
|
<span>{((store.castProgress || 0) * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={Math.min(100, (store.castProgress || 0) * 100)} className="h-2 bg-gray-800" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSpellDef.desc && (
|
||||||
|
<div className="text-xs text-gray-500 italic">{activeSpellDef.desc}</div>
|
||||||
|
)}
|
||||||
|
{activeSpellDef.effects && activeSpellDef.effects.length > 0 && (
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{activeSpellDef.effects.map((eff, i) => (
|
||||||
|
<Badge key={i} variant="outline" className="text-xs">
|
||||||
|
{eff.type === 'burn' && `🔥 Burn ${eff.value}/hr`}
|
||||||
|
{eff.type === 'stun' && `⚡ Stun ${eff.value}s`}
|
||||||
|
{eff.type === 'pierce' && `🗡️ Pierce ${Math.round(eff.value * 100)}%`}
|
||||||
|
{eff.type === 'multicast' && `✨ ${Math.round(eff.value * 100)}% Multicast`}
|
||||||
|
{eff.type === 'buff' && `💪 Buff`}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-500">No spell selected</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Can cast indicator */}
|
||||||
|
{activeSpellDef && (
|
||||||
|
<div className={`text-xs ${canCastSpell(store.activeSpell) ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{canCastSpell(store.activeSpell) ? '✓ Can cast' : '✗ Insufficient mana'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{incursionStrength > 0 && (
|
||||||
|
<div className="p-2 bg-red-900/20 border border-red-800/50 rounded">
|
||||||
|
<div className="text-xs text-red-400 game-panel-title mb-1">LABYRINTH INCURSION</div>
|
||||||
|
<div className="text-sm text-gray-300">
|
||||||
|
-{Math.round(incursionStrength * 100)}% mana regen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Current Study (if any) */}
|
||||||
|
{store.currentStudyTarget && (
|
||||||
|
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
{renderStudyProgress()}
|
||||||
|
|
||||||
|
{/* Parallel Study Progress */}
|
||||||
|
{store.parallelStudyTarget && (
|
||||||
|
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-sm font-semibold text-cyan-300">
|
||||||
|
Parallel: {store.parallelStudyTarget.type === 'skill' ? store.parallelStudyTarget.id : store.parallelStudyTarget.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={() => store.cancelParallelStudy()}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
|
||||||
|
<span>50% speed (Parallel Study)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pact Signing Progress */}
|
||||||
|
{store.pactSigningProgress && (
|
||||||
|
<Card className="bg-gray-900/80 border-amber-600/50 lg:col-span-2">
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
<div className="p-3 rounded border border-amber-500/30 bg-amber-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">📜</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-amber-300">
|
||||||
|
Signing Pact: {GUARDIANS[store.pactSigningProgress.floor]?.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-amber-400">
|
||||||
|
Floor {store.pactSigningProgress.floor}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={Math.min(100, (store.pactSigningProgress.progress / store.pactSigningProgress.required) * 100)}
|
||||||
|
className="h-2 bg-gray-800"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-amber-400 mt-1">
|
||||||
|
<span>{formatStudyTime(store.pactSigningProgress.progress)} / {formatStudyTime(store.pactSigningProgress.required)}</span>
|
||||||
|
<span>Cost: {fmt(store.pactSigningProgress.manaCost)} mana</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spells Available */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Known Spells</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||||
|
{Object.entries(store.spells)
|
||||||
|
.filter(([, state]) => state.learned)
|
||||||
|
.map(([id, state]) => {
|
||||||
|
const def = SPELLS_DEF[id];
|
||||||
|
if (!def) return null;
|
||||||
|
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||||
|
const isActive = store.activeSpell === id;
|
||||||
|
const canCast = canCastSpell(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={id}
|
||||||
|
variant="outline"
|
||||||
|
className={`h-auto py-2 px-3 flex flex-col items-start ${isActive ? 'border-amber-500 bg-amber-900/20' : canCast ? 'border-gray-600 bg-gray-800/50 hover:bg-gray-700/50' : 'border-gray-700 bg-gray-800/30 opacity-60'}`}
|
||||||
|
onClick={() => store.setSpell(id)}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||||
|
{def.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 game-mono">
|
||||||
|
{fmt(calcDamage(store, id))} dmg
|
||||||
|
</div>
|
||||||
|
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||||
|
{formatSpellCost(def.cost)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Activity Log */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-32">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{store.log.slice(0, 20).map((entry, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
|
||||||
|
>
|
||||||
|
{entry}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
551
src/components/game/StatsTab.tsx
Executable file
551
src/components/game/StatsTab.tsx
Executable file
@@ -0,0 +1,551 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useGameStore, fmt, fmtDec, calcDamage, computePactMultiplier, computePactInsightMultiplier } from '@/lib/game/store';
|
||||||
|
import { ELEMENTS, GUARDIANS, SPELLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||||
|
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Droplet, Swords, BookOpen, FlaskConical, RotateCcw, Trophy, Star } from 'lucide-react';
|
||||||
|
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||||
|
|
||||||
|
export function StatsTab() {
|
||||||
|
const store = useGameStore();
|
||||||
|
const {
|
||||||
|
upgradeEffects, maxMana, baseRegen, clickMana,
|
||||||
|
meditationMultiplier, incursionStrength, manaCascadeBonus, effectiveRegen,
|
||||||
|
hasSteadyStream, hasManaTorrent, hasDesperateWells
|
||||||
|
} = useManaStats();
|
||||||
|
const { activeSpellDef, pactMultiplier, pactInsightMultiplier } = useCombatStats();
|
||||||
|
const { studySpeedMult, studyCostMult } = useStudyStats();
|
||||||
|
|
||||||
|
// Compute element max
|
||||||
|
const elemMax = (() => {
|
||||||
|
const ea = store.skillTiers?.elemAttune || 1;
|
||||||
|
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
|
||||||
|
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
|
||||||
|
const tierMult = getTierMultiplier(tieredSkillId);
|
||||||
|
return 10 + level * 50 * tierMult + (store.prestigeUpgrades.elementalAttune || 0) * 25;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Get all selected skill upgrades
|
||||||
|
const getAllSelectedUpgrades = () => {
|
||||||
|
const upgrades: { skillId: string; upgrade: SkillUpgradeChoice }[] = [];
|
||||||
|
for (const [skillId, selectedIds] of Object.entries(store.skillUpgrades)) {
|
||||||
|
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||||
|
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||||
|
if (!path) continue;
|
||||||
|
for (const tier of path.tiers) {
|
||||||
|
if (tier.skillId === skillId) {
|
||||||
|
for (const upgradeId of selectedIds) {
|
||||||
|
const upgrade = tier.upgrades.find(u => u.id === upgradeId);
|
||||||
|
if (upgrade) {
|
||||||
|
upgrades.push({ skillId, upgrade });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return upgrades;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedUpgrades = getAllSelectedUpgrades();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Mana Stats */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-blue-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Droplet className="w-4 h-4" />
|
||||||
|
Mana Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Base Max Mana:</span>
|
||||||
|
<span className="text-gray-200">100</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Well Bonus:</span>
|
||||||
|
<span className="text-blue-300">
|
||||||
|
{(() => {
|
||||||
|
const mw = store.skillTiers?.manaWell || 1;
|
||||||
|
const tieredSkillId = mw > 1 ? `manaWell_t${mw}` : 'manaWell';
|
||||||
|
const level = store.skills[tieredSkillId] || store.skills.manaWell || 0;
|
||||||
|
const tierMult = getTierMultiplier(tieredSkillId);
|
||||||
|
return `+${fmt(level * 100 * tierMult)} (${level} lvl × 100 × ${tierMult}x tier)`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Prestige Mana Well:</span>
|
||||||
|
<span className="text-blue-300">+{fmt((store.prestigeUpgrades.manaWell || 0) * 500)}</span>
|
||||||
|
</div>
|
||||||
|
{upgradeEffects.maxManaBonus > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-amber-400">Upgrade Mana Bonus:</span>
|
||||||
|
<span className="text-amber-300">+{fmt(upgradeEffects.maxManaBonus)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgradeEffects.maxManaMultiplier > 1 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-amber-400">Upgrade Mana Multiplier:</span>
|
||||||
|
<span className="text-amber-300">×{fmtDec(upgradeEffects.maxManaMultiplier, 2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||||
|
<span className="text-gray-300">Total Max Mana:</span>
|
||||||
|
<span className="text-blue-400">{fmt(maxMana)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Base Regen:</span>
|
||||||
|
<span className="text-gray-200">2/hr</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Flow Bonus:</span>
|
||||||
|
<span className="text-blue-300">
|
||||||
|
{(() => {
|
||||||
|
const mf = store.skillTiers?.manaFlow || 1;
|
||||||
|
const tieredSkillId = mf > 1 ? `manaFlow_t${mf}` : 'manaFlow';
|
||||||
|
const level = store.skills[tieredSkillId] || store.skills.manaFlow || 0;
|
||||||
|
const tierMult = getTierMultiplier(tieredSkillId);
|
||||||
|
return `+${fmtDec(level * 1 * tierMult)}/hr (${level} lvl × 1 × ${tierMult}x tier)`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Spring Bonus:</span>
|
||||||
|
<span className="text-blue-300">+{(store.skills.manaSpring || 0) * 2}/hr</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Prestige Mana Flow:</span>
|
||||||
|
<span className="text-blue-300">+{fmtDec((store.prestigeUpgrades.manaFlow || 0) * 0.5)}/hr</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Temporal Echo:</span>
|
||||||
|
<span className="text-blue-300">×{fmtDec(1 + (store.prestigeUpgrades.temporalEcho || 0) * 0.1, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||||
|
<span className="text-gray-300">Base Regen:</span>
|
||||||
|
<span className="text-blue-400">{fmtDec(baseRegen, 2)}/hr</span>
|
||||||
|
</div>
|
||||||
|
{upgradeEffects.regenBonus > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-amber-400">Upgrade Regen Bonus:</span>
|
||||||
|
<span className="text-amber-300">+{fmtDec(upgradeEffects.regenBonus, 2)}/hr</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgradeEffects.permanentRegenBonus > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-amber-400">Permanent Regen Bonus:</span>
|
||||||
|
<span className="text-amber-300">+{fmtDec(upgradeEffects.permanentRegenBonus, 2)}/hr</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgradeEffects.regenMultiplier > 1 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-amber-400">Upgrade Regen Multiplier:</span>
|
||||||
|
<span className="text-amber-300">×{fmtDec(upgradeEffects.regenMultiplier, 2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-gray-700 my-3" />
|
||||||
|
{/* Skill Upgrade Effects Summary */}
|
||||||
|
{upgradeEffects.activeUpgrades.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-xs text-amber-400 game-panel-title">Active Skill Upgrades</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3">
|
||||||
|
{upgradeEffects.activeUpgrades.map((upgrade, idx) => (
|
||||||
|
<div key={idx} className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||||
|
<span className="text-gray-300">{upgrade.name}</span>
|
||||||
|
<span className="text-gray-400">{upgrade.desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-gray-700 my-3" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Click Mana Value:</span>
|
||||||
|
<span className="text-purple-300">+{clickMana}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Tap Bonus:</span>
|
||||||
|
<span className="text-purple-300">+{store.skills.manaTap || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Surge Bonus:</span>
|
||||||
|
<span className="text-purple-300">+{(store.skills.manaSurge || 0) * 3}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Overflow:</span>
|
||||||
|
<span className="text-purple-300">×{fmtDec(1 + (store.skills.manaOverflow || 0) * 0.25, 2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Meditation Multiplier:</span>
|
||||||
|
<span className={`font-semibold ${meditationMultiplier > 1.5 ? 'text-purple-400' : 'text-gray-300'}`}>
|
||||||
|
{fmtDec(meditationMultiplier, 2)}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Effective Regen:</span>
|
||||||
|
<span className="text-green-400 font-semibold">{fmtDec(effectiveRegen, 2)}/hr</span>
|
||||||
|
</div>
|
||||||
|
{incursionStrength > 0 && !hasSteadyStream && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-red-400">Incursion Penalty:</span>
|
||||||
|
<span className="text-red-400">-{Math.round(incursionStrength * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasSteadyStream && incursionStrength > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-green-400">Steady Stream:</span>
|
||||||
|
<span className="text-green-400">Immune to incursion</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{manaCascadeBonus > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-cyan-400">Mana Cascade Bonus:</span>
|
||||||
|
<span className="text-cyan-400">+{fmtDec(manaCascadeBonus, 2)}/hr</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasManaTorrent && store.rawMana > maxMana * 0.75 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-cyan-400">Mana Torrent:</span>
|
||||||
|
<span className="text-cyan-400">+50% regen (high mana)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasDesperateWells && store.rawMana < maxMana * 0.25 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-cyan-400">Desperate Wells:</span>
|
||||||
|
<span className="text-cyan-400">+50% regen (low mana)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Combat Stats */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-red-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Swords className="w-4 h-4" />
|
||||||
|
Combat Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Active Spell Base Damage:</span>
|
||||||
|
<span className="text-gray-200">{activeSpellDef?.dmg || 5}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Combat Training Bonus:</span>
|
||||||
|
<span className="text-red-300">+{(store.skills.combatTrain || 0) * 5}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Arcane Fury Multiplier:</span>
|
||||||
|
<span className="text-red-300">×{fmtDec(1 + (store.skills.arcaneFury || 0) * 0.1, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Elemental Mastery:</span>
|
||||||
|
<span className="text-red-300">×{fmtDec(1 + (store.skills.elementalMastery || 0) * 0.15, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Guardian Bane:</span>
|
||||||
|
<span className="text-red-300">×{fmtDec(1 + (store.skills.guardianBane || 0) * 0.2, 2)} (vs guardians)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Critical Hit Chance:</span>
|
||||||
|
<span className="text-amber-300">{((store.skills.precision || 0) * 5)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Critical Multiplier:</span>
|
||||||
|
<span className="text-amber-300">1.5x</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Spell Echo Chance:</span>
|
||||||
|
<span className="text-amber-300">{((store.skills.spellEcho || 0) * 10)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Pact Multiplier:</span>
|
||||||
|
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||||
|
<span className="text-gray-300">Total Damage:</span>
|
||||||
|
<span className="text-red-400">{fmt(calcDamage(store, store.activeSpell))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pact Status */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Trophy className="w-4 h-4" />
|
||||||
|
Pact Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Pact Slots:</span>
|
||||||
|
<span className="text-amber-300">{store.signedPacts.length} / {1 + (store.prestigeUpgrades.pactCapacity || 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Damage Multiplier:</span>
|
||||||
|
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Insight Multiplier:</span>
|
||||||
|
<span className="text-purple-300">×{fmtDec(pactInsightMultiplier, 2)}</span>
|
||||||
|
</div>
|
||||||
|
{store.signedPacts.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Interference Mitigation:</span>
|
||||||
|
<span className="text-green-300">{Math.min(store.pactInterferenceMitigation || 0, 5) * 10}%</span>
|
||||||
|
</div>
|
||||||
|
{(store.pactInterferenceMitigation || 0) >= 5 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-cyan-400">Synergy Bonus:</span>
|
||||||
|
<span className="text-cyan-300">+{((store.pactInterferenceMitigation || 0) - 5) * 10}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm text-gray-400 mb-2">Unlocked Mana Types:</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Object.entries(store.elements)
|
||||||
|
.filter(([, state]) => state.unlocked)
|
||||||
|
.map(([id]) => {
|
||||||
|
const elem = ELEMENTS[id];
|
||||||
|
return (
|
||||||
|
<Badge key={id} variant="outline" className="text-xs" style={{ borderColor: elem?.color, color: elem?.color }}>
|
||||||
|
{elem?.sym} {elem?.name}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Study Stats */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
Study Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Study Speed:</span>
|
||||||
|
<span className="text-purple-300">×{fmtDec(studySpeedMult, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Quick Learner Bonus:</span>
|
||||||
|
<span className="text-purple-300">+{((store.skills.quickLearner || 0) * 10)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Study Cost:</span>
|
||||||
|
<span className="text-purple-300">{Math.round(studyCostMult * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Focused Mind Bonus:</span>
|
||||||
|
<span className="text-purple-300">-{((store.skills.focusedMind || 0) * 5)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Progress Retention:</span>
|
||||||
|
<span className="text-purple-300">{Math.round((1 + (store.skills.knowledgeRetention || 0) * 0.2) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Element Stats */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-green-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<FlaskConical className="w-4 h-4" />
|
||||||
|
Element Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Element Capacity:</span>
|
||||||
|
<span className="text-green-300">{elemMax}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Elem. Attunement Bonus:</span>
|
||||||
|
<span className="text-green-300">
|
||||||
|
{(() => {
|
||||||
|
const ea = store.skillTiers?.elemAttune || 1;
|
||||||
|
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
|
||||||
|
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
|
||||||
|
const tierMult = getTierMultiplier(tieredSkillId);
|
||||||
|
return `+${level * 50 * tierMult}`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Prestige Attunement:</span>
|
||||||
|
<span className="text-green-300">+{(store.prestigeUpgrades.elementalAttune || 0) * 25}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Unlocked Elements:</span>
|
||||||
|
<span className="text-green-300">{Object.values(store.elements).filter(e => e.unlocked).length} / {Object.keys(ELEMENTS).length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Elem. Crafting Bonus:</span>
|
||||||
|
<span className="text-green-300">×{fmtDec(1 + (store.skills.elemCrafting || 0) * 0.25, 2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-gray-700 my-3" />
|
||||||
|
<div className="text-xs text-gray-400 mb-2">Elemental Mana Pools:</div>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||||
|
{Object.entries(store.elements)
|
||||||
|
.filter(([, state]) => state.unlocked)
|
||||||
|
.map(([id, state]) => {
|
||||||
|
const def = ELEMENTS[id];
|
||||||
|
return (
|
||||||
|
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 text-center">
|
||||||
|
<div className="text-lg">{def?.sym}</div>
|
||||||
|
<div className="text-xs text-gray-400">{state.current}/{state.max}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Upgrades */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
Active Skill Upgrades ({selectedUpgrades.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{selectedUpgrades.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-sm">No skill upgrades selected yet. Level skills to 5 or 10 to choose upgrades.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
{selectedUpgrades.map(({ skillId, upgrade }) => (
|
||||||
|
<div key={upgrade.id} className="p-2 rounded border border-amber-600/30 bg-amber-900/10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-amber-300 text-sm font-semibold">{upgrade.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs text-gray-400">
|
||||||
|
{SKILLS_DEF[skillId]?.name || skillId}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||||
|
{upgrade.effect.type === 'multiplier' && (
|
||||||
|
<div className="text-xs text-green-400 mt-1">
|
||||||
|
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgrade.effect.type === 'bonus' && (
|
||||||
|
<div className="text-xs text-blue-400 mt-1">
|
||||||
|
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgrade.effect.type === 'special' && (
|
||||||
|
<div className="text-xs text-cyan-400 mt-1">
|
||||||
|
⚡ {upgrade.effect.specialDesc || 'Special effect active'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Loop Stats */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Loop Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||||
|
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Current Insight</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Insight</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-400 game-mono">{store.maxFloorReached}</div>
|
||||||
|
<div className="text-xs text-gray-400">Max Floor</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-gray-700 my-3" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.spells).filter(s => s.learned).length}</div>
|
||||||
|
<div className="text-xs text-gray-400">Spells Learned</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.skills).reduce((a, b) => a + b, 0)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Skill Levels</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-gray-300 game-mono">{fmt(store.totalManaGathered)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Mana Gathered</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-gray-300 game-mono">{store.memorySlots}</div>
|
||||||
|
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/game/StudyProgress.tsx
Executable file
57
src/components/game/StudyProgress.tsx
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { BookOpen, X } from 'lucide-react';
|
||||||
|
import { SKILLS_DEF, SPELLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { formatStudyTime } from '@/lib/game/formatting';
|
||||||
|
import type { StudyTarget } from '@/lib/game/types';
|
||||||
|
|
||||||
|
interface StudyProgressProps {
|
||||||
|
currentStudyTarget: StudyTarget | null;
|
||||||
|
skills: Record<string, number>;
|
||||||
|
studySpeedMult: number;
|
||||||
|
cancelStudy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudyProgress({
|
||||||
|
currentStudyTarget,
|
||||||
|
skills,
|
||||||
|
studySpeedMult,
|
||||||
|
cancelStudy,
|
||||||
|
}: StudyProgressProps) {
|
||||||
|
if (!currentStudyTarget) return null;
|
||||||
|
|
||||||
|
const target = currentStudyTarget;
|
||||||
|
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||||
|
const isSkill = target.type === 'skill';
|
||||||
|
const def = isSkill ? SKILLS_DEF[target.id] : SPELLS_DEF[target.id];
|
||||||
|
const currentLevel = isSkill ? (skills[target.id] || 0) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-sm font-semibold text-purple-300">
|
||||||
|
{def?.name}
|
||||||
|
{isSkill && ` Lv.${currentLevel + 1}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={cancelStudy}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
|
||||||
|
<span>{studySpeedMult.toFixed(1)}x speed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/game/TimeDisplay.tsx
Executable file
51
src/components/game/TimeDisplay.tsx
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Play, Pause } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { fmt } from '@/lib/game/store';
|
||||||
|
import { formatHour } from '@/lib/game/formatting';
|
||||||
|
|
||||||
|
interface TimeDisplayProps {
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
insight: number;
|
||||||
|
paused: boolean;
|
||||||
|
onTogglePause: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeDisplay({
|
||||||
|
day,
|
||||||
|
hour,
|
||||||
|
insight,
|
||||||
|
paused,
|
||||||
|
onTogglePause,
|
||||||
|
}: TimeDisplayProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold game-mono text-amber-400">
|
||||||
|
Day {day}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{formatHour(hour)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold game-mono text-purple-400">
|
||||||
|
{fmt(insight)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Insight</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onTogglePause}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
{paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/components/game/UpgradeDialog.tsx
Executable file
115
src/components/game/UpgradeDialog.tsx
Executable file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||||
|
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
export interface UpgradeDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
skillId: string | null;
|
||||||
|
milestone: 5 | 10;
|
||||||
|
pendingSelections: string[];
|
||||||
|
available: SkillUpgradeChoice[];
|
||||||
|
alreadySelected: string[];
|
||||||
|
onToggle: (upgradeId: string) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpgradeDialog({
|
||||||
|
open,
|
||||||
|
skillId,
|
||||||
|
milestone,
|
||||||
|
pendingSelections,
|
||||||
|
available,
|
||||||
|
alreadySelected,
|
||||||
|
onToggle,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
onOpenChange,
|
||||||
|
}: UpgradeDialogProps) {
|
||||||
|
if (!skillId) return null;
|
||||||
|
|
||||||
|
const skillDef = SKILLS_DEF[skillId];
|
||||||
|
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-amber-400">
|
||||||
|
Choose Upgrade - {skillDef?.name || skillId}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
Level {milestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
{available.map((upgrade) => {
|
||||||
|
const isSelected = currentSelections.includes(upgrade.id);
|
||||||
|
const canToggle = currentSelections.length < 2 || isSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={upgrade.id}
|
||||||
|
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-amber-500 bg-amber-900/30'
|
||||||
|
: canToggle
|
||||||
|
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||||
|
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (canToggle) {
|
||||||
|
onToggle(upgrade.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
|
||||||
|
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||||
|
{upgrade.effect.type === 'multiplier' && (
|
||||||
|
<div className="text-xs text-green-400 mt-1">
|
||||||
|
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgrade.effect.type === 'bonus' && (
|
||||||
|
<div className="text-xs text-blue-400 mt-1">
|
||||||
|
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgrade.effect.type === 'special' && (
|
||||||
|
<div className="text-xs text-cyan-400 mt-1">
|
||||||
|
⚡ {upgrade.effect.specialDesc || 'Special effect'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={currentSelections.length !== 2}
|
||||||
|
>
|
||||||
|
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/components/game/index.ts
Executable file
19
src/components/game/index.ts
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
// ─── Game Components Index ──────────────────────────────────────────────────────
|
||||||
|
// Re-exports all game tab components for cleaner imports
|
||||||
|
|
||||||
|
// Tab components
|
||||||
|
export { CraftingTab } from './tabs/CraftingTab';
|
||||||
|
export { SpireTab } from './tabs/SpireTab';
|
||||||
|
export { SpellsTab } from './tabs/SpellsTab';
|
||||||
|
export { LabTab } from './tabs/LabTab';
|
||||||
|
export { SkillsTab } from './tabs/SkillsTab';
|
||||||
|
export { StatsTab } from './tabs/StatsTab';
|
||||||
|
|
||||||
|
// UI components
|
||||||
|
export { ActionButtons } from './ActionButtons';
|
||||||
|
export { CalendarDisplay } from './CalendarDisplay';
|
||||||
|
export { CraftingProgress } from './CraftingProgress';
|
||||||
|
export { StudyProgress } from './StudyProgress';
|
||||||
|
export { ManaDisplay } from './ManaDisplay';
|
||||||
|
export { TimeDisplay } from './TimeDisplay';
|
||||||
|
export { UpgradeDialog } from './UpgradeDialog';
|
||||||
19
src/components/game/layout/GameFooter.tsx
Executable file
19
src/components/game/layout/GameFooter.tsx
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useGameContext } from '../GameContext';
|
||||||
|
|
||||||
|
export function GameFooter() {
|
||||||
|
const { store } = useGameContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="sticky bottom-0 bg-gray-900/80 border-t border-gray-700 px-4 py-2 text-center text-xs text-gray-500">
|
||||||
|
<span className="text-gray-400">Loop {store.loopCount + 1}</span>
|
||||||
|
{' • '}
|
||||||
|
<span>Pacts: {store.signedPacts.length}/{store.pactSlots}</span>
|
||||||
|
{' • '}
|
||||||
|
<span>Spells: {Object.values(store.spells).filter((s) => s.learned).length}</span>
|
||||||
|
{' • '}
|
||||||
|
<span>Skills: {Object.values(store.skills).reduce((a, b) => a + b, 0)}</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/game/layout/GameHeader.tsx
Executable file
79
src/components/game/layout/GameHeader.tsx
Executable file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { Pause, Play } from 'lucide-react';
|
||||||
|
import { useGameContext } from '../GameContext';
|
||||||
|
import { formatTime } from '../types';
|
||||||
|
import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
|
||||||
|
import { fmt } from '@/lib/game/stores';
|
||||||
|
|
||||||
|
export function GameHeader() {
|
||||||
|
const { store } = useGameContext();
|
||||||
|
|
||||||
|
// Calendar rendering
|
||||||
|
const renderCalendar = () => {
|
||||||
|
const days: React.ReactElement[] = [];
|
||||||
|
for (let d = 1; d <= MAX_DAY; d++) {
|
||||||
|
let dayClass = 'w-7 h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
|
||||||
|
|
||||||
|
if (d < store.day) {
|
||||||
|
dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
|
||||||
|
} else if (d === store.day) {
|
||||||
|
dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30';
|
||||||
|
} else {
|
||||||
|
dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d >= INCURSION_START_DAY) {
|
||||||
|
dayClass += ' border-red-600/50';
|
||||||
|
}
|
||||||
|
|
||||||
|
days.push(
|
||||||
|
<Tooltip key={d}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className={dayClass}>{d}</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Day {d}</p>
|
||||||
|
{d >= INCURSION_START_DAY && <p className="text-red-400">Incursion Active</p>}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 bg-gradient-to-b from-gray-900 to-gray-900/80 border-b border-gray-700 px-4 py-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold game-title tracking-wider">MANA LOOP</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold game-mono text-amber-400">Day {store.day}</div>
|
||||||
|
<div className="text-xs text-gray-400">{formatTime(store.hour)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold game-mono text-purple-400">{fmt(store.insight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Insight</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => store.togglePause()}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
{store.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar */}
|
||||||
|
<div className="mt-2 flex gap-1 overflow-x-auto pb-1">{renderCalendar()}</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/components/game/layout/GameSidebar.tsx
Executable file
141
src/components/game/layout/GameSidebar.tsx
Executable file
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Zap, Sparkles, Swords, BookOpen, FlaskConical, type LucideIcon } from 'lucide-react';
|
||||||
|
import { useGameContext } from '../GameContext';
|
||||||
|
import { fmt, fmtDec } from '@/lib/game/stores';
|
||||||
|
import type { GameAction } from '@/lib/game/types';
|
||||||
|
|
||||||
|
export function GameSidebar() {
|
||||||
|
const {
|
||||||
|
store,
|
||||||
|
maxMana,
|
||||||
|
clickMana,
|
||||||
|
effectiveRegen,
|
||||||
|
meditationMultiplier,
|
||||||
|
floorElemDef,
|
||||||
|
} = useGameContext();
|
||||||
|
|
||||||
|
const [isGathering, setIsGathering] = useState(false);
|
||||||
|
|
||||||
|
// Auto-gather while holding
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGathering) return;
|
||||||
|
|
||||||
|
let lastGatherTime = 0;
|
||||||
|
const minGatherInterval = 100;
|
||||||
|
let animationFrameId: number;
|
||||||
|
|
||||||
|
const gatherLoop = (timestamp: number) => {
|
||||||
|
if (timestamp - lastGatherTime >= minGatherInterval) {
|
||||||
|
store.gatherMana();
|
||||||
|
lastGatherTime = timestamp;
|
||||||
|
}
|
||||||
|
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||||
|
return () => cancelAnimationFrame(animationFrameId);
|
||||||
|
}, [isGathering, store]);
|
||||||
|
|
||||||
|
const handleGatherStart = useCallback(() => {
|
||||||
|
setIsGathering(true);
|
||||||
|
store.gatherMana();
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
const handleGatherEnd = useCallback(() => {
|
||||||
|
setIsGathering(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
const actions: { id: GameAction; label: string; icon: LucideIcon }[] = [
|
||||||
|
{ id: 'meditate', label: 'Meditate', icon: Sparkles },
|
||||||
|
{ id: 'climb', label: 'Climb', icon: Swords },
|
||||||
|
{ id: 'study', label: 'Study', icon: BookOpen },
|
||||||
|
{ id: 'convert', label: 'Convert', icon: FlaskConical },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-full md:w-64 bg-gray-900/50 border-b md:border-b-0 md:border-r border-gray-700 p-4 space-y-4">
|
||||||
|
{/* Mana Panel */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-3xl font-bold game-mono text-blue-400">{fmt(store.rawMana)}</span>
|
||||||
|
<span className="text-sm text-gray-400">/ {fmt(maxMana)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
+{fmtDec(effectiveRegen)} mana/hr{' '}
|
||||||
|
{meditationMultiplier > 1.01 && (
|
||||||
|
<span className="text-purple-400">({fmtDec(meditationMultiplier, 1)}x med)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress value={(store.rawMana / maxMana) * 100} className="h-2 bg-gray-800" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className={`w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 ${
|
||||||
|
isGathering ? 'animate-pulse' : ''
|
||||||
|
}`}
|
||||||
|
onMouseDown={handleGatherStart}
|
||||||
|
onMouseUp={handleGatherEnd}
|
||||||
|
onMouseLeave={handleGatherEnd}
|
||||||
|
onTouchStart={handleGatherStart}
|
||||||
|
onTouchEnd={handleGatherEnd}
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4 mr-2" />
|
||||||
|
Gather +{clickMana} Mana
|
||||||
|
{isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="text-xs text-amber-400 game-panel-title mb-2">Current Action</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{actions.map(({ id, label, icon: Icon }) => (
|
||||||
|
<Button
|
||||||
|
key={id}
|
||||||
|
variant={store.currentAction === id ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={`h-9 ${
|
||||||
|
store.currentAction === id
|
||||||
|
? 'bg-blue-600 hover:bg-blue-700'
|
||||||
|
: 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => store.setAction(id)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 mr-1" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Floor Status */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardContent className="pt-4 space-y-2">
|
||||||
|
<div className="text-xs text-amber-400 game-panel-title mb-2">Floor Status</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-400">Current</span>
|
||||||
|
<span className="text-lg font-bold" style={{ color: floorElemDef?.color }}>
|
||||||
|
{store.currentFloor}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={(store.floorHP / store.floorMaxHP) * 100} className="h-2 bg-gray-800" />
|
||||||
|
<div className="text-xs text-gray-400 game-mono">
|
||||||
|
{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/game/shared/GameOverScreen.tsx
Executable file
76
src/components/game/shared/GameOverScreen.tsx
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useGameContext } from '../GameContext';
|
||||||
|
import { fmt } from '@/lib/game/stores';
|
||||||
|
import { MemorySlotPicker } from './MemorySlotPicker';
|
||||||
|
|
||||||
|
export function GameOverScreen() {
|
||||||
|
const { store } = useGameContext();
|
||||||
|
const [memoriesConfirmed, setMemoriesConfirmed] = useState(false);
|
||||||
|
|
||||||
|
if (!store.gameOver) return null;
|
||||||
|
|
||||||
|
const handleStartNewLoop = () => {
|
||||||
|
store.startNewLoop();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 game-overlay flex items-center justify-center z-50 overflow-auto py-4">
|
||||||
|
<div className="max-w-lg w-full mx-4 space-y-4">
|
||||||
|
<Card className="bg-gray-900 border-gray-600 shadow-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle
|
||||||
|
className={`text-3xl text-center game-title ${store.victory ? 'text-amber-400' : 'text-red-400'}`}
|
||||||
|
>
|
||||||
|
{store.victory ? '🏆 VICTORY!' : '⏰ LOOP ENDS'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-center text-gray-400">
|
||||||
|
{store.victory
|
||||||
|
? 'The Awakened One falls! Your power echoes through eternity.'
|
||||||
|
: 'The time loop resets... but you remember.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 bg-gray-800 rounded">
|
||||||
|
<div className="text-xl font-bold text-amber-400 game-mono">{fmt(store.loopInsight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Insight Gained</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded">
|
||||||
|
<div className="text-xl font-bold text-blue-400 game-mono">{store.maxFloorReached}</div>
|
||||||
|
<div className="text-xs text-gray-400">Best Floor</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded">
|
||||||
|
<div className="text-xl font-bold text-purple-400 game-mono">{store.signedPacts.length}</div>
|
||||||
|
<div className="text-xs text-gray-400">Pacts Signed</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded">
|
||||||
|
<div className="text-xl font-bold text-green-400 game-mono">{store.loopCount + 1}</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Loops</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Memory Slot Picker */}
|
||||||
|
{store.memorySlots > 0 && !memoriesConfirmed && (
|
||||||
|
<MemorySlotPicker onConfirm={() => setMemoriesConfirmed(true)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleStartNewLoop}
|
||||||
|
disabled={store.memorySlots > 0 && !memoriesConfirmed}
|
||||||
|
>
|
||||||
|
Begin New Loop
|
||||||
|
{store.memorySlots > 0 && !memoriesConfirmed && ' (Confirm Memories First)'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/components/game/shared/MemorySlotPicker.tsx
Executable file
206
src/components/game/shared/MemorySlotPicker.tsx
Executable file
@@ -0,0 +1,206 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Save, Trash2, Star, ChevronUp } from 'lucide-react';
|
||||||
|
import { useGameContext } from '../GameContext';
|
||||||
|
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { getTierMultiplier, getBaseSkillId } from '@/lib/game/skill-evolution';
|
||||||
|
import type { Memory } from '@/lib/game/types';
|
||||||
|
|
||||||
|
interface MemorySlotPickerProps {
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemorySlotPicker({ onConfirm }: MemorySlotPickerProps) {
|
||||||
|
const { store } = useGameContext();
|
||||||
|
const [selectedSkills, setSelectedSkills] = useState<Memory[]>(store.memories || []);
|
||||||
|
|
||||||
|
// Get all skills that have progress and can be saved
|
||||||
|
const saveableSkills = useMemo(() => {
|
||||||
|
const skills: { skillId: string; level: number; tier: number; upgrades: string[]; name: string }[] = [];
|
||||||
|
|
||||||
|
for (const [skillId, level] of Object.entries(store.skills)) {
|
||||||
|
if (level && level > 0) {
|
||||||
|
const baseSkillId = getBaseSkillId(skillId);
|
||||||
|
const tier = store.skillTiers?.[baseSkillId] || 1;
|
||||||
|
const tieredSkillId = tier > 1 ? `${baseSkillId}_t${tier}` : baseSkillId;
|
||||||
|
const upgrades = store.skillUpgrades?.[tieredSkillId] || [];
|
||||||
|
const skillDef = SKILLS_DEF[baseSkillId];
|
||||||
|
|
||||||
|
// Only include if it's a base skill (not a tiered variant in the skills object)
|
||||||
|
if (skillId === baseSkillId || skillId.includes('_t')) {
|
||||||
|
// Get the actual skill ID and level
|
||||||
|
const actualLevel = store.skills[tieredSkillId] || store.skills[baseSkillId] || 0;
|
||||||
|
if (actualLevel > 0) {
|
||||||
|
skills.push({
|
||||||
|
skillId: baseSkillId,
|
||||||
|
level: actualLevel,
|
||||||
|
tier,
|
||||||
|
upgrades,
|
||||||
|
name: skillDef?.name || baseSkillId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicates and keep highest tier/level
|
||||||
|
const uniqueSkills = new Map<string, typeof skills[0]>();
|
||||||
|
for (const skill of skills) {
|
||||||
|
const existing = uniqueSkills.get(skill.skillId);
|
||||||
|
if (!existing || skill.tier > existing.tier || (skill.tier === existing.tier && skill.level > existing.level)) {
|
||||||
|
uniqueSkills.set(skill.skillId, skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(uniqueSkills.values()).sort((a, b) => {
|
||||||
|
// Sort by tier then level then name
|
||||||
|
if (a.tier !== b.tier) return b.tier - a.tier;
|
||||||
|
if (a.level !== b.level) return b.level - a.level;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}, [store.skills, store.skillTiers, store.skillUpgrades]);
|
||||||
|
|
||||||
|
const isSkillSelected = (skillId: string) => selectedSkills.some(m => m.skillId === skillId);
|
||||||
|
|
||||||
|
const canAddMore = selectedSkills.length < store.memorySlots;
|
||||||
|
|
||||||
|
const toggleSkill = (skillId: string) => {
|
||||||
|
const existingIndex = selectedSkills.findIndex(m => m.skillId === skillId);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Remove it
|
||||||
|
setSelectedSkills(selectedSkills.filter((_, i) => i !== existingIndex));
|
||||||
|
} else if (canAddMore) {
|
||||||
|
// Add it
|
||||||
|
const skill = saveableSkills.find(s => s.skillId === skillId);
|
||||||
|
if (skill) {
|
||||||
|
setSelectedSkills([...selectedSkills, {
|
||||||
|
skillId: skill.skillId,
|
||||||
|
level: skill.level,
|
||||||
|
tier: skill.tier,
|
||||||
|
upgrades: skill.upgrades,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
// Clear and re-add selected memories
|
||||||
|
store.clearMemories();
|
||||||
|
for (const memory of selectedSkills) {
|
||||||
|
store.addMemory(memory);
|
||||||
|
}
|
||||||
|
onConfirm?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-sm flex items-center gap-2">
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Memory Slots ({selectedSkills.length}/{store.memorySlots})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Select skills to preserve in your memory. Saved skills will retain their level, tier, and upgrades in the next loop.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Selected Skills */}
|
||||||
|
{selectedSkills.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-green-400 game-panel-title">Saved to Memory:</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{selectedSkills.map((memory) => {
|
||||||
|
const skillDef = SKILLS_DEF[memory.skillId];
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={memory.skillId}
|
||||||
|
className="bg-amber-900/50 text-amber-200 cursor-pointer hover:bg-red-900/50"
|
||||||
|
onClick={() => toggleSkill(memory.skillId)}
|
||||||
|
>
|
||||||
|
{skillDef?.name || memory.skillId}
|
||||||
|
{' '}Lv.{memory.level}
|
||||||
|
{memory.tier > 1 && ` T${memory.tier}`}
|
||||||
|
{memory.upgrades.length > 0 && ` (${memory.upgrades.length}⭐)`}
|
||||||
|
<Trash2 className="w-3 h-3 ml-1" />
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available Skills */}
|
||||||
|
<div className="text-xs text-gray-400 game-panel-title">Skills to Save:</div>
|
||||||
|
<ScrollArea className="h-48">
|
||||||
|
<div className="space-y-1 pr-2">
|
||||||
|
{saveableSkills.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-xs text-center py-4">
|
||||||
|
No skills with progress to save
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
saveableSkills.map((skill) => {
|
||||||
|
const isSelected = isSkillSelected(skill.skillId);
|
||||||
|
const tierMult = getTierMultiplier(skill.tier > 1 ? `${skill.skillId}_t${skill.tier}` : skill.skillId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={skill.skillId}
|
||||||
|
className={`p-2 rounded border cursor-pointer transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-amber-500 bg-amber-900/30'
|
||||||
|
: canAddMore
|
||||||
|
? 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
|
||||||
|
: 'border-gray-800 bg-gray-900/30 opacity-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleSkill(skill.skillId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-sm">{skill.name}</span>
|
||||||
|
{skill.tier > 1 && (
|
||||||
|
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
|
||||||
|
Tier {skill.tier} ({tierMult}x)
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-purple-400 text-sm">Lv.{skill.level}</span>
|
||||||
|
{skill.upgrades.length > 0 && (
|
||||||
|
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
|
||||||
|
<Star className="w-3 h-3" />
|
||||||
|
{skill.upgrades.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{skill.upgrades.length > 0 && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
Upgrades: {skill.upgrades.length} selected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Confirm Button */}
|
||||||
|
<Button
|
||||||
|
className="w-full bg-amber-600 hover:bg-amber-700"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Confirm Memories
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/components/game/shared/StudyProgress.tsx
Executable file
60
src/components/game/shared/StudyProgress.tsx
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { BookOpen, X } from 'lucide-react';
|
||||||
|
import { useGameContext } from '../GameContext';
|
||||||
|
import { formatStudyTime } from '../types';
|
||||||
|
import { SKILLS_DEF, SPELLS_DEF } from '@/lib/game/constants';
|
||||||
|
|
||||||
|
interface StudyProgressProps {
|
||||||
|
target: NonNullable<ReturnType<typeof useGameContext>['store']['currentStudyTarget']>;
|
||||||
|
showCancel?: boolean;
|
||||||
|
speedLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudyProgress({ target, showCancel = true, speedLabel }: StudyProgressProps) {
|
||||||
|
const { store, studySpeedMult } = useGameContext();
|
||||||
|
|
||||||
|
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||||
|
const isSkill = target.type === 'skill';
|
||||||
|
const def = isSkill ? SKILLS_DEF[target.id] : SPELLS_DEF[target.id];
|
||||||
|
const currentLevel = isSkill ? store.skills[target.id] || 0 : 0;
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
// Calculate retention bonus from knowledge retention skill
|
||||||
|
const retentionBonus = 0.2 * (store.skills.knowledgeRetention || 0);
|
||||||
|
store.cancelStudy(retentionBonus);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-sm font-semibold text-purple-300">
|
||||||
|
{def?.name}
|
||||||
|
{isSkill && ` Lv.${currentLevel + 1}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showCancel && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>
|
||||||
|
{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}
|
||||||
|
</span>
|
||||||
|
<span>{speedLabel ?? `${studySpeedMult.toFixed(1)}x speed`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/game/shared/UpgradeDialog.tsx
Executable file
126
src/components/game/shared/UpgradeDialog.tsx
Executable file
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useGameContext } from '../GameContext';
|
||||||
|
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||||
|
|
||||||
|
interface UpgradeDialogProps {
|
||||||
|
skillId: string | null;
|
||||||
|
milestone: 5 | 10;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpgradeDialog({ skillId, milestone, onClose }: UpgradeDialogProps) {
|
||||||
|
const { store } = useGameContext();
|
||||||
|
|
||||||
|
const skillDef = skillId ? SKILLS_DEF[skillId] : null;
|
||||||
|
const { available, selected: alreadySelected } = skillId
|
||||||
|
? store.getSkillUpgradeChoices(skillId, milestone)
|
||||||
|
: { available: [], selected: [] };
|
||||||
|
|
||||||
|
// Use local state for selections within this dialog session
|
||||||
|
const [pendingSelections, setPendingSelections] = useState<string[]>(() => [...alreadySelected]);
|
||||||
|
|
||||||
|
const toggleUpgrade = (upgradeId: string) => {
|
||||||
|
setPendingSelections((prev) => {
|
||||||
|
if (prev.includes(upgradeId)) {
|
||||||
|
return prev.filter((id) => id !== upgradeId);
|
||||||
|
} else if (prev.length < 2) {
|
||||||
|
return [...prev, upgradeId];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
if (pendingSelections.length === 2 && skillId) {
|
||||||
|
store.commitSkillUpgrades(skillId, pendingSelections);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setPendingSelections([...alreadySelected]);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render if no skill selected
|
||||||
|
if (!skillId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!skillId} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-amber-400">Choose Upgrade - {skillDef?.name || skillId}</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
Level {milestone} Milestone - Select 2 upgrades ({pendingSelections.length}/2 chosen)
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
{available.map((upgrade) => {
|
||||||
|
const isSelected = pendingSelections.includes(upgrade.id);
|
||||||
|
const canToggle = pendingSelections.length < 2 || isSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={upgrade.id}
|
||||||
|
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-amber-500 bg-amber-900/30'
|
||||||
|
: canToggle
|
||||||
|
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||||
|
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (canToggle) {
|
||||||
|
toggleUpgrade(upgrade.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
|
||||||
|
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||||
|
{upgrade.effect.type === 'multiplier' && (
|
||||||
|
<div className="text-xs text-green-400 mt-1">
|
||||||
|
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgrade.effect.type === 'bonus' && (
|
||||||
|
<div className="text-xs text-blue-400 mt-1">
|
||||||
|
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgrade.effect.type === 'special' && (
|
||||||
|
<div className="text-xs text-cyan-400 mt-1">⚡ {upgrade.desc || 'Special effect'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setPendingSelections([...alreadySelected]);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" onClick={handleDone} disabled={pendingSelections.length !== 2}>
|
||||||
|
{pendingSelections.length < 2 ? `Select ${2 - pendingSelections.length} more` : 'Confirm'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/game/tabs/AchievementsTab.tsx
Executable file
43
src/components/game/tabs/AchievementsTab.tsx
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { GameStore } from '@/lib/game/store';
|
||||||
|
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
|
||||||
|
|
||||||
|
export interface AchievementsTabProps {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AchievementsTab({ store }: AchievementsTabProps) {
|
||||||
|
const achievements = store.achievements;
|
||||||
|
const unlockedCount = achievements.unlocked.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||||
|
🏆 Achievements
|
||||||
|
<Badge className="ml-auto bg-amber-900/50 text-amber-300">
|
||||||
|
{unlockedCount} unlocked
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AchievementsDisplay
|
||||||
|
achievements={achievements}
|
||||||
|
gameState={{
|
||||||
|
maxFloorReached: store.maxFloorReached,
|
||||||
|
totalManaGathered: store.totalManaGathered,
|
||||||
|
signedPacts: store.signedPacts,
|
||||||
|
totalSpellsCast: store.totalSpellsCast,
|
||||||
|
totalDamageDealt: store.totalDamageDealt,
|
||||||
|
totalCraftsCompleted: store.totalCraftsCompleted,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
src/components/game/tabs/AttunementsTab.tsx
Executable file
268
src/components/game/tabs/AttunementsTab.tsx
Executable file
@@ -0,0 +1,268 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getTotalAttunementRegen, getAvailableSkillCategories, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL, getAttunementConversionRate } from '@/lib/game/data/attunements';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
|
import type { GameStore, AttunementState } from '@/lib/game/types';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Lock, Sparkles, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface AttunementsTabProps {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttunementsTab({ store }: AttunementsTabProps) {
|
||||||
|
const attunements = store.attunements || {};
|
||||||
|
|
||||||
|
// Get active attunements
|
||||||
|
const activeAttunements = Object.entries(attunements)
|
||||||
|
.filter(([, state]) => state.active)
|
||||||
|
.map(([id]) => ATTUNEMENTS_DEF[id])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Calculate total regen from attunements
|
||||||
|
const totalAttunementRegen = getTotalAttunementRegen(attunements);
|
||||||
|
|
||||||
|
// Get available skill categories
|
||||||
|
const availableCategories = getAvailableSkillCategories(attunements);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Overview Card */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">Your Attunements</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-gray-400 mb-3">
|
||||||
|
Attunements are magical bonds tied to specific body locations. Each attunement grants unique capabilities,
|
||||||
|
mana regeneration, and access to specialized skills. Level them up to increase their power.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge className="bg-teal-900/50 text-teal-300">
|
||||||
|
+{totalAttunementRegen.toFixed(1)} raw mana/hr
|
||||||
|
</Badge>
|
||||||
|
<Badge className="bg-purple-900/50 text-purple-300">
|
||||||
|
{activeAttunements.length} active attunement{activeAttunements.length !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Attunement Slots */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
|
||||||
|
const state = attunements[id];
|
||||||
|
const isActive = state?.active;
|
||||||
|
const isUnlocked = state?.active || def.unlocked;
|
||||||
|
const level = state?.level || 1;
|
||||||
|
const xp = state?.experience || 0;
|
||||||
|
const xpNeeded = getAttunementXPForLevel(level + 1);
|
||||||
|
const xpProgress = xpNeeded > 0 ? (xp / xpNeeded) * 100 : 100;
|
||||||
|
const isMaxLevel = level >= MAX_ATTUNEMENT_LEVEL;
|
||||||
|
|
||||||
|
// Get primary mana element info
|
||||||
|
const primaryElem = def.primaryManaType ? ELEMENTS[def.primaryManaType] : null;
|
||||||
|
|
||||||
|
// Get current mana for this attunement's type
|
||||||
|
const currentMana = def.primaryManaType ? store.elements[def.primaryManaType]?.current || 0 : 0;
|
||||||
|
const maxMana = def.primaryManaType ? store.elements[def.primaryManaType]?.max || 50 : 50;
|
||||||
|
|
||||||
|
// Calculate level-scaled stats
|
||||||
|
const levelMult = Math.pow(1.5, level - 1);
|
||||||
|
const scaledRegen = def.rawManaRegen * levelMult;
|
||||||
|
const scaledConversion = getAttunementConversionRate(id, level);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={id}
|
||||||
|
className={`bg-gray-900/80 transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'border-2 shadow-lg'
|
||||||
|
: isUnlocked
|
||||||
|
? 'border-gray-600'
|
||||||
|
: 'border-gray-800 opacity-70'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
borderColor: isActive ? def.color : undefined,
|
||||||
|
boxShadow: isActive ? `0 0 20px ${def.color}30` : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">{def.icon}</span>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-sm" style={{ color: isActive ? def.color : '#9CA3AF' }}>
|
||||||
|
{def.name}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{ATTUNEMENT_SLOT_NAMES[def.slot]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isUnlocked && (
|
||||||
|
<Lock className="w-4 h-4 text-gray-600" />
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<Badge className="text-xs" style={{ backgroundColor: `${def.color}30`, color: def.color }}>
|
||||||
|
Lv.{level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-xs text-gray-400">{def.desc}</p>
|
||||||
|
|
||||||
|
{/* Mana Type */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500">Primary Mana</span>
|
||||||
|
{primaryElem ? (
|
||||||
|
<span style={{ color: primaryElem.color }}>
|
||||||
|
{primaryElem.sym} {primaryElem.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-purple-400">From Pacts</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mana bar (only for attunements with primary type) */}
|
||||||
|
{primaryElem && isActive && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Progress
|
||||||
|
value={(currentMana / maxMana) * 100}
|
||||||
|
className="h-2 bg-gray-800"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>{currentMana.toFixed(1)}</span>
|
||||||
|
<span>/{maxMana}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats with level scaling */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="p-2 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-gray-500">Raw Regen</div>
|
||||||
|
<div className="text-green-400 font-semibold">
|
||||||
|
+{scaledRegen.toFixed(2)}/hr
|
||||||
|
{level > 1 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-gray-500">Conversion</div>
|
||||||
|
<div className="text-cyan-400 font-semibold">
|
||||||
|
{scaledConversion > 0 ? `${scaledConversion.toFixed(2)}/hr` : '—'}
|
||||||
|
{level > 1 && scaledConversion > 0 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* XP Progress Bar */}
|
||||||
|
{isUnlocked && state && !isMaxLevel && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500 flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3 h-3" />
|
||||||
|
XP Progress
|
||||||
|
</span>
|
||||||
|
<span className="text-amber-400">{xp} / {xpNeeded}</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={xpProgress}
|
||||||
|
className="h-2 bg-gray-800"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{isMaxLevel ? 'Max Level' : `${xpNeeded - xp} XP to Level ${level + 1}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Max Level Indicator */}
|
||||||
|
{isMaxLevel && (
|
||||||
|
<div className="text-xs text-amber-400 text-center font-semibold">
|
||||||
|
✨ MAX LEVEL ✨
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Capabilities */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-gray-500">Capabilities</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{def.capabilities.map(cap => (
|
||||||
|
<Badge key={cap} variant="outline" className="text-xs">
|
||||||
|
{cap === 'enchanting' && '✨ Enchanting'}
|
||||||
|
{cap === 'disenchanting' && '🔄 Disenchant'}
|
||||||
|
{cap === 'pacts' && '🤝 Pacts'}
|
||||||
|
{cap === 'guardianPowers' && '💜 Guardian Powers'}
|
||||||
|
{cap === 'elementalMastery' && '🌟 Elem. Mastery'}
|
||||||
|
{cap === 'golemCrafting' && '🗿 Golems'}
|
||||||
|
{cap === 'gearCrafting' && '⚒️ Gear'}
|
||||||
|
{cap === 'earthShaping' && '⛰️ Earth Shaping'}
|
||||||
|
{!['enchanting', 'disenchanting', 'pacts', 'guardianPowers',
|
||||||
|
'elementalMastery', 'golemCrafting', 'gearCrafting', 'earthShaping'].includes(cap) && cap}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unlock condition for locked attunements */}
|
||||||
|
{!isUnlocked && def.unlockCondition && (
|
||||||
|
<div className="text-xs text-amber-400 italic">
|
||||||
|
🔒 {def.unlockCondition}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Skills Summary */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">Available Skill Categories</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-xs text-gray-400 mb-2">
|
||||||
|
Your attunements grant access to specialized skill categories:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableCategories.map(cat => {
|
||||||
|
const attunement = Object.values(ATTUNEMENTS_DEF).find(a =>
|
||||||
|
a.skillCategories.includes(cat) && attunements[a.id]?.active
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={cat}
|
||||||
|
className={attunement ? '' : 'bg-gray-700/50 text-gray-400'}
|
||||||
|
style={attunement ? {
|
||||||
|
backgroundColor: `${attunement.color}30`,
|
||||||
|
color: attunement.color
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
{cat === 'mana' && '💧 Mana'}
|
||||||
|
{cat === 'study' && '📚 Study'}
|
||||||
|
{cat === 'research' && '🔮 Research'}
|
||||||
|
{cat === 'ascension' && '⭐ Ascension'}
|
||||||
|
{cat === 'enchant' && '✨ Enchanting'}
|
||||||
|
{cat === 'effectResearch' && '🔬 Effect Research'}
|
||||||
|
{cat === 'invocation' && '💜 Invocation'}
|
||||||
|
{cat === 'pact' && '🤝 Pact Mastery'}
|
||||||
|
{cat === 'fabrication' && '⚒️ Fabrication'}
|
||||||
|
{cat === 'golemancy' && '🗿 Golemancy'}
|
||||||
|
{!['mana', 'study', 'research', 'ascension', 'enchant', 'effectResearch',
|
||||||
|
'invocation', 'pact', 'fabrication', 'golemancy'].includes(cat) && cat}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
965
src/components/game/tabs/CraftingTab.tsx
Executable file
965
src/components/game/tabs/CraftingTab.tsx
Executable file
@@ -0,0 +1,965 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import {
|
||||||
|
Wand2, Scroll, Hammer, Sparkles, Trash2, Plus, Minus,
|
||||||
|
Package, Zap, Clock, ChevronRight, Circle, Anvil
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { EQUIPMENT_TYPES, type EquipmentType, type EquipmentSlot } from '@/lib/game/data/equipment';
|
||||||
|
import { ENCHANTMENT_EFFECTS, type EnchantmentEffectDef, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
|
||||||
|
import { CRAFTING_RECIPES, canCraftRecipe } from '@/lib/game/data/crafting-recipes';
|
||||||
|
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||||
|
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, AppliedEnchantment, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
|
||||||
|
import { fmt, type GameStore } from '@/lib/game/store';
|
||||||
|
|
||||||
|
// Slot display names
|
||||||
|
const SLOT_NAMES: Record<EquipmentSlot, string> = {
|
||||||
|
mainHand: 'Main Hand',
|
||||||
|
offHand: 'Off Hand',
|
||||||
|
head: 'Head',
|
||||||
|
body: 'Body',
|
||||||
|
hands: 'Hands',
|
||||||
|
feet: 'Feet',
|
||||||
|
accessory1: 'Accessory 1',
|
||||||
|
accessory2: 'Accessory 2',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CraftingTabProps {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CraftingTab({ store }: CraftingTabProps) {
|
||||||
|
const equippedInstances = store.equippedInstances;
|
||||||
|
const equipmentInstances = store.equipmentInstances;
|
||||||
|
const enchantmentDesigns = store.enchantmentDesigns;
|
||||||
|
const designProgress = store.designProgress;
|
||||||
|
const preparationProgress = store.preparationProgress;
|
||||||
|
const applicationProgress = store.applicationProgress;
|
||||||
|
const equipmentCraftingProgress = store.equipmentCraftingProgress;
|
||||||
|
const rawMana = store.rawMana;
|
||||||
|
const skills = store.skills;
|
||||||
|
const currentAction = store.currentAction;
|
||||||
|
const unlockedEffects = store.unlockedEffects;
|
||||||
|
const lootInventory = store.lootInventory;
|
||||||
|
const startDesigningEnchantment = store.startDesigningEnchantment;
|
||||||
|
const cancelDesign = store.cancelDesign;
|
||||||
|
const saveDesign = store.saveDesign;
|
||||||
|
const deleteDesign = store.deleteDesign;
|
||||||
|
const startPreparing = store.startPreparing;
|
||||||
|
const cancelPreparation = store.cancelPreparation;
|
||||||
|
const startApplying = store.startApplying;
|
||||||
|
const pauseApplication = store.pauseApplication;
|
||||||
|
const resumeApplication = store.resumeApplication;
|
||||||
|
const cancelApplication = store.cancelApplication;
|
||||||
|
const disenchantEquipment = store.disenchantEquipment;
|
||||||
|
const getAvailableCapacity = store.getAvailableCapacity;
|
||||||
|
const startCraftingEquipment = store.startCraftingEquipment;
|
||||||
|
const cancelEquipmentCrafting = store.cancelEquipmentCrafting;
|
||||||
|
const deleteMaterial = store.deleteMaterial;
|
||||||
|
const [craftingStage, setCraftingStage] = useState<'design' | 'prepare' | 'apply' | 'craft'>('craft');
|
||||||
|
const [selectedEquipmentType, setSelectedEquipmentType] = useState<string | null>(null);
|
||||||
|
const [selectedEquipmentInstance, setSelectedEquipmentInstance] = useState<string | null>(null);
|
||||||
|
const [selectedDesign, setSelectedDesign] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Design creation state
|
||||||
|
const [designName, setDesignName] = useState('');
|
||||||
|
const [selectedEffects, setSelectedEffects] = useState<DesignEffect[]>([]);
|
||||||
|
|
||||||
|
const enchantingLevel = skills.enchanting || 0;
|
||||||
|
const efficiencyBonus = (skills.efficientEnchant || 0) * 0.05;
|
||||||
|
|
||||||
|
// Get equipped items as array
|
||||||
|
const equippedItems = Object.entries(equippedInstances)
|
||||||
|
.filter(([, instanceId]) => instanceId && equipmentInstances[instanceId])
|
||||||
|
.map(([slot, instanceId]) => ({
|
||||||
|
slot: slot as EquipmentSlot,
|
||||||
|
instance: equipmentInstances[instanceId!],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate total capacity cost for current design
|
||||||
|
const designCapacityCost = selectedEffects.reduce(
|
||||||
|
(total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get capacity limit for selected equipment type
|
||||||
|
const selectedEquipmentCapacity = selectedEquipmentType ? EQUIPMENT_TYPES[selectedEquipmentType]?.baseCapacity || 0 : 0;
|
||||||
|
const isOverCapacity = selectedEquipmentType ? designCapacityCost > selectedEquipmentCapacity : false;
|
||||||
|
|
||||||
|
// Calculate design time
|
||||||
|
const designTime = selectedEffects.reduce((total, eff) => total + 0.5 * eff.stacks, 1);
|
||||||
|
|
||||||
|
// Add effect to design
|
||||||
|
const addEffect = (effectId: string) => {
|
||||||
|
const existing = selectedEffects.find(e => e.effectId === effectId);
|
||||||
|
const effectDef = ENCHANTMENT_EFFECTS[effectId];
|
||||||
|
if (!effectDef) return;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.stacks < effectDef.maxStacks) {
|
||||||
|
setSelectedEffects(selectedEffects.map(e =>
|
||||||
|
e.effectId === effectId
|
||||||
|
? { ...e, stacks: e.stacks + 1 }
|
||||||
|
: e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedEffects([...selectedEffects, {
|
||||||
|
effectId,
|
||||||
|
stacks: 1,
|
||||||
|
capacityCost: calculateEffectCapacityCost(effectId, 1, efficiencyBonus),
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove effect from design
|
||||||
|
const removeEffect = (effectId: string) => {
|
||||||
|
const existing = selectedEffects.find(e => e.effectId === effectId);
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
if (existing.stacks > 1) {
|
||||||
|
setSelectedEffects(selectedEffects.map(e =>
|
||||||
|
e.effectId === effectId
|
||||||
|
? { ...e, stacks: e.stacks - 1 }
|
||||||
|
: e
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
setSelectedEffects(selectedEffects.filter(e => e.effectId !== effectId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create design
|
||||||
|
const handleCreateDesign = () => {
|
||||||
|
if (!designName || !selectedEquipmentType || selectedEffects.length === 0) return;
|
||||||
|
|
||||||
|
const success = startDesigningEnchantment(designName, selectedEquipmentType, selectedEffects);
|
||||||
|
if (success) {
|
||||||
|
// Reset form
|
||||||
|
setDesignName('');
|
||||||
|
setSelectedEquipmentType(null);
|
||||||
|
setSelectedEffects([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get available effects for selected equipment type (only unlocked ones)
|
||||||
|
const getAvailableEffects = () => {
|
||||||
|
if (!selectedEquipmentType) return [];
|
||||||
|
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||||
|
if (!type) return [];
|
||||||
|
|
||||||
|
return Object.values(ENCHANTMENT_EFFECTS).filter(
|
||||||
|
effect =>
|
||||||
|
effect.allowedEquipmentCategories.includes(type.category) &&
|
||||||
|
unlockedEffects.includes(effect.id)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render design stage
|
||||||
|
const renderDesignStage = () => (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Equipment Type Selection */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">1. Select Equipment Type</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{designProgress ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Designing for: {EQUIPMENT_TYPES[designProgress.equipmentType]?.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-amber-300">{designProgress.name}</div>
|
||||||
|
<Progress value={(designProgress.progress / designProgress.required) * 100} className="h-3" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={cancelDesign}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.values(EQUIPMENT_TYPES).map(type => (
|
||||||
|
<div
|
||||||
|
key={type.id}
|
||||||
|
className={`p-2 rounded border cursor-pointer transition-all ${
|
||||||
|
selectedEquipmentType === type.id
|
||||||
|
? 'border-amber-500 bg-amber-900/20'
|
||||||
|
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedEquipmentType(type.id)}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold">{type.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">Cap: {type.baseCapacity}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Effect Selection */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">2. Select Effects</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{enchantingLevel < 1 ? (
|
||||||
|
<div className="text-center text-gray-400 py-8">
|
||||||
|
<Wand2 className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>Learn Enchanting skill to design enchantments</p>
|
||||||
|
</div>
|
||||||
|
) : designProgress ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm text-gray-400">Design in progress...</div>
|
||||||
|
{designProgress.effects.map(eff => {
|
||||||
|
const def = ENCHANTMENT_EFFECTS[eff.effectId];
|
||||||
|
return (
|
||||||
|
<div key={eff.effectId} className="flex justify-between text-sm">
|
||||||
|
<span>{def?.name} x{eff.stacks}</span>
|
||||||
|
<span className="text-gray-400">{eff.capacityCost} cap</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : !selectedEquipmentType ? (
|
||||||
|
<div className="text-center text-gray-400 py-8">
|
||||||
|
Select an equipment type first
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ScrollArea className="h-48 mb-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{getAvailableEffects().map(effect => {
|
||||||
|
const selected = selectedEffects.find(e => e.effectId === effect.id);
|
||||||
|
const cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={effect.id}
|
||||||
|
className={`p-2 rounded border transition-all ${
|
||||||
|
selected
|
||||||
|
? 'border-purple-500 bg-purple-900/20'
|
||||||
|
: 'border-gray-700 bg-gray-800/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-semibold">{effect.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">{effect.description}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
Cost: {effect.baseCapacityCost} cap | Max: {effect.maxStacks}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{selected && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => removeEffect(effect.id)}
|
||||||
|
>
|
||||||
|
<Minus className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => addEffect(effect.id)}
|
||||||
|
disabled={!selected && selectedEffects.length >= 5}
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selected && (
|
||||||
|
<Badge variant="outline" className="mt-1 text-xs">
|
||||||
|
{selected.stacks}/{effect.maxStacks}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Selected effects summary */}
|
||||||
|
<Separator className="bg-gray-700 my-2" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Design name..."
|
||||||
|
value={designName}
|
||||||
|
onChange={(e) => setDesignName(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>Total Capacity:</span>
|
||||||
|
<span className={isOverCapacity ? 'text-red-400' : 'text-green-400'}>
|
||||||
|
{designCapacityCost.toFixed(0)} / {selectedEquipmentCapacity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm text-gray-400">
|
||||||
|
<span>Design Time:</span>
|
||||||
|
<span>{designTime.toFixed(1)}h</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={!designName || selectedEffects.length === 0 || isOverCapacity}
|
||||||
|
onClick={handleCreateDesign}
|
||||||
|
>
|
||||||
|
{isOverCapacity ? 'Over Capacity!' : `Start Design (${designTime.toFixed(1)}h)`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Saved Designs */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">Saved Designs ({enchantmentDesigns.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{enchantmentDesigns.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-400 py-4">
|
||||||
|
No saved designs yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{enchantmentDesigns.map(design => (
|
||||||
|
<div
|
||||||
|
key={design.id}
|
||||||
|
className={`p-3 rounded border ${
|
||||||
|
selectedDesign === design.id
|
||||||
|
? 'border-amber-500 bg-amber-900/20'
|
||||||
|
: 'border-gray-700 bg-gray-800/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedDesign(design.id)}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{design.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{EQUIPMENT_TYPES[design.equipmentType]?.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-red-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteDesign(design.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-400">
|
||||||
|
{design.effects.length} effects | {design.totalCapacityUsed} cap
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render prepare stage
|
||||||
|
const renderPrepareStage = () => (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Equipment Selection */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">Select Equipment to Prepare or Disenchant</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{preparationProgress ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Preparing: {equipmentInstances[preparationProgress.equipmentInstanceId]?.name}
|
||||||
|
</div>
|
||||||
|
<Progress value={(preparationProgress.progress / preparationProgress.required) * 100} className="h-3" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>{preparationProgress.progress.toFixed(1)}h / {preparationProgress.required.toFixed(1)}h</span>
|
||||||
|
<span>Mana paid: {fmt(preparationProgress.manaCostPaid)}</span>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={cancelPreparation}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{equippedItems.map(({ slot, instance }) => {
|
||||||
|
const hasEnchantments = instance.enchantments.length > 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instance.instanceId}
|
||||||
|
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||||
|
selectedEquipmentInstance === instance.instanceId
|
||||||
|
? 'border-amber-500 bg-amber-900/20'
|
||||||
|
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
|
||||||
|
} ${hasEnchantments ? 'border-l-4 border-l-red-600' : ''}`}
|
||||||
|
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{instance.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">{SLOT_NAMES[slot]}</div>
|
||||||
|
{hasEnchantments && (
|
||||||
|
<div className="text-xs text-red-400 mt-1">
|
||||||
|
⚠️ {instance.enchantments.length} enchantments - Disenchant to apply new
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm">
|
||||||
|
<div className="text-green-400">{instance.usedCapacity}/{instance.totalCapacity} cap</div>
|
||||||
|
<div className="text-xs text-gray-400">{instance.enchantments.length} enchants</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{equippedItems.length === 0 && (
|
||||||
|
<div className="text-center text-gray-400 py-4">No equipped items</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Preparation Details */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">Preparation Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!selectedEquipmentInstance ? (
|
||||||
|
<div className="text-center text-gray-400 py-8">
|
||||||
|
Select equipment to prepare or disenchant
|
||||||
|
</div>
|
||||||
|
) : preparationProgress ? (
|
||||||
|
<div className="text-gray-400">Preparation in progress...</div>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const instance = equipmentInstances[selectedEquipmentInstance];
|
||||||
|
const hasEnchantments = instance.enchantments.length > 0;
|
||||||
|
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
|
||||||
|
const manaCost = instance.totalCapacity * 10;
|
||||||
|
|
||||||
|
// Calculate disenchant recovery
|
||||||
|
const disenchantLevel = skills.disenchanting || 0;
|
||||||
|
const recoveryRate = 0.1 + disenchantLevel * 0.2;
|
||||||
|
const totalRecoverable = instance.enchantments.reduce(
|
||||||
|
(sum, e) => sum + Math.floor(e.actualCost * recoveryRate),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-lg font-semibold">{instance.name}</div>
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
|
{/* Disenchant option for enchanted gear */}
|
||||||
|
{hasEnchantments && (
|
||||||
|
<div className="p-3 rounded border border-red-600/50 bg-red-900/20 space-y-3">
|
||||||
|
<div className="text-sm font-semibold text-red-400">⚠️ Equipment has enchantments</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
You must disenchant before applying new enchantments.
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Recoverable Mana:</span>
|
||||||
|
<span className="text-green-400">{fmt(totalRecoverable)}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={() => disenchantEquipment(instance.instanceId)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Disenchant & Recover {fmt(totalRecoverable)} Mana
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prepare option for non-enchanted gear */}
|
||||||
|
{!hasEnchantments && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Capacity:</span>
|
||||||
|
<span>{instance.usedCapacity}/{instance.totalCapacity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Prep Time:</span>
|
||||||
|
<span>{prepTime}h</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Mana Cost:</span>
|
||||||
|
<span className={rawMana < manaCost ? 'text-red-400' : 'text-green-400'}>
|
||||||
|
{fmt(manaCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={rawMana < manaCost}
|
||||||
|
onClick={() => startPreparing(selectedEquipmentInstance)}
|
||||||
|
>
|
||||||
|
Start Preparation ({prepTime}h, {fmt(manaCost)} mana)
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render apply stage
|
||||||
|
const renderApplyStage = () => (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Equipment & Design Selection */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">Select Equipment & Design</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{applicationProgress ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Applying to: {equipmentInstances[applicationProgress.equipmentInstanceId]?.name}
|
||||||
|
</div>
|
||||||
|
<Progress value={(applicationProgress.progress / applicationProgress.required) * 100} className="h-3" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>{applicationProgress.progress.toFixed(1)}h / {applicationProgress.required.toFixed(1)}h</span>
|
||||||
|
<span>Mana spent: {fmt(applicationProgress.manaSpent)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{applicationProgress.paused ? (
|
||||||
|
<Button size="sm" onClick={resumeApplication}>Resume</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={pauseApplication}>Pause</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={cancelApplication}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-400 mb-2">Equipment (without enchantments):</div>
|
||||||
|
<ScrollArea className="h-32">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{equippedItems
|
||||||
|
.filter(({ instance }) => instance.enchantments.length === 0)
|
||||||
|
.map(({ slot, instance }) => (
|
||||||
|
<div
|
||||||
|
key={instance.instanceId}
|
||||||
|
className={`p-2 rounded border cursor-pointer text-sm ${
|
||||||
|
selectedEquipmentInstance === instance.instanceId
|
||||||
|
? 'border-amber-500 bg-amber-900/20'
|
||||||
|
: 'border-gray-700 bg-gray-800/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
|
||||||
|
>
|
||||||
|
{instance.name} ({instance.usedCapacity}/{instance.totalCapacity} cap)
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{equippedItems.filter(({ instance }) => instance.enchantments.length === 0).length === 0 && (
|
||||||
|
<div className="text-center text-gray-500 text-xs py-2">
|
||||||
|
No unenchanted equipment available. Disenchant in Prepare stage first.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-400 mb-2">Design:</div>
|
||||||
|
<ScrollArea className="h-32">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{enchantmentDesigns.map(design => (
|
||||||
|
<div
|
||||||
|
key={design.id}
|
||||||
|
className={`p-2 rounded border cursor-pointer text-sm ${
|
||||||
|
selectedDesign === design.id
|
||||||
|
? 'border-purple-500 bg-purple-900/20'
|
||||||
|
: 'border-gray-700 bg-gray-800/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedDesign(design.id)}
|
||||||
|
>
|
||||||
|
{design.name} ({design.totalCapacityUsed} cap)
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Application Details */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">Apply Enchantment</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!selectedEquipmentInstance || !selectedDesign ? (
|
||||||
|
<div className="text-center text-gray-400 py-8">
|
||||||
|
Select equipment and a design
|
||||||
|
</div>
|
||||||
|
) : applicationProgress ? (
|
||||||
|
<div className="text-gray-400">Application in progress...</div>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const instance = equipmentInstances[selectedEquipmentInstance];
|
||||||
|
const design = enchantmentDesigns.find(d => d.id === selectedDesign);
|
||||||
|
if (!design) return null;
|
||||||
|
|
||||||
|
const availableCap = instance.totalCapacity - instance.usedCapacity;
|
||||||
|
const canFit = availableCap >= design.totalCapacityUsed;
|
||||||
|
const applicationTime = 2 + design.effects.reduce((t, e) => t + e.stacks, 0);
|
||||||
|
const manaPerHour = 20 + design.effects.reduce((t, e) => t + e.stacks * 5, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-lg font-semibold">{design.name}</div>
|
||||||
|
<div className="text-sm text-gray-400">→ {instance.name}</div>
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Required Capacity:</span>
|
||||||
|
<span className={canFit ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{design.totalCapacityUsed} / {availableCap} available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Application Time:</span>
|
||||||
|
<span>{applicationTime}h</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Mana per Hour:</span>
|
||||||
|
<span>{manaPerHour}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Effects:
|
||||||
|
<ul className="list-disc list-inside">
|
||||||
|
{design.effects.map(eff => (
|
||||||
|
<li key={eff.effectId}>
|
||||||
|
{ENCHANTMENT_EFFECTS[eff.effectId]?.name} x{eff.stacks}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canFit}
|
||||||
|
onClick={() => startApplying(selectedEquipmentInstance, selectedDesign)}
|
||||||
|
>
|
||||||
|
Apply Enchantment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render equipment crafting stage
|
||||||
|
const renderCraftStage = () => (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Blueprint Selection */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||||
|
<Anvil className="w-4 h-4" />
|
||||||
|
Available Blueprints
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{equipmentCraftingProgress ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Crafting: {CRAFTING_RECIPES[equipmentCraftingProgress.blueprintId]?.name}
|
||||||
|
</div>
|
||||||
|
<Progress value={(equipmentCraftingProgress.progress / equipmentCraftingProgress.required) * 100} className="h-3" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>{equipmentCraftingProgress.progress.toFixed(1)}h / {equipmentCraftingProgress.required.toFixed(1)}h</span>
|
||||||
|
<span>Mana spent: {fmt(equipmentCraftingProgress.manaSpent)}</span>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lootInventory.blueprints.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-400 py-4">
|
||||||
|
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No blueprints discovered yet.</p>
|
||||||
|
<p className="text-xs mt-1">Defeat guardians to find blueprints!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
lootInventory.blueprints.map(bpId => {
|
||||||
|
const recipe = CRAFTING_RECIPES[bpId];
|
||||||
|
if (!recipe) return null;
|
||||||
|
|
||||||
|
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
|
||||||
|
recipe,
|
||||||
|
lootInventory.materials,
|
||||||
|
rawMana
|
||||||
|
);
|
||||||
|
|
||||||
|
const rarityStyle = RARITY_COLORS[recipe.rarity];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={bpId}
|
||||||
|
className="p-3 rounded border bg-gray-800/50"
|
||||||
|
style={{ borderColor: rarityStyle?.color }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold" style={{ color: rarityStyle?.color }}>
|
||||||
|
{recipe.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 capitalize">{recipe.rarity}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{EQUIPMENT_TYPES[recipe.equipmentTypeId]?.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-400 mb-2">{recipe.description}</div>
|
||||||
|
|
||||||
|
<Separator className="bg-gray-700 my-2" />
|
||||||
|
|
||||||
|
<div className="text-xs space-y-1">
|
||||||
|
<div className="text-gray-500">Materials:</div>
|
||||||
|
{Object.entries(recipe.materials).map(([matId, amount]) => {
|
||||||
|
const available = lootInventory.materials[matId] || 0;
|
||||||
|
const matDrop = LOOT_DROPS[matId];
|
||||||
|
const hasEnough = available >= amount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={matId} className="flex justify-between">
|
||||||
|
<span>{matDrop?.name || matId}</span>
|
||||||
|
<span className={hasEnough ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{available} / {amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-2">
|
||||||
|
<span>Mana Cost:</span>
|
||||||
|
<span className={rawMana >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{fmt(recipe.manaCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Craft Time:</span>
|
||||||
|
<span>{recipe.craftTime}h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full mt-3"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canCraft || currentAction === 'craft'}
|
||||||
|
onClick={() => startCraftingEquipment(bpId)}
|
||||||
|
>
|
||||||
|
{canCraft ? 'Craft Equipment' : 'Missing Resources'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Materials Inventory */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
Materials ({Object.values(lootInventory.materials).reduce((a, b) => a + b, 0)})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
{Object.keys(lootInventory.materials).length === 0 ? (
|
||||||
|
<div className="text-center text-gray-400 py-4">
|
||||||
|
<Sparkles className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No materials collected yet.</p>
|
||||||
|
<p className="text-xs mt-1">Defeat floors to gather materials!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.entries(lootInventory.materials).map(([matId, count]) => {
|
||||||
|
if (count <= 0) return null;
|
||||||
|
const drop = LOOT_DROPS[matId];
|
||||||
|
if (!drop) return null;
|
||||||
|
|
||||||
|
const rarityStyle = RARITY_COLORS[drop.rarity];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={matId}
|
||||||
|
className="p-2 rounded border bg-gray-800/50 group relative"
|
||||||
|
style={{ borderColor: rarityStyle?.color }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
|
||||||
|
{drop.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">x{count}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||||
|
onClick={() => deleteMaterial(matId, count)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stage Tabs */}
|
||||||
|
<Tabs value={craftingStage} onValueChange={(v) => setCraftingStage(v as typeof craftingStage)}>
|
||||||
|
<TabsList className="bg-gray-800/50">
|
||||||
|
<TabsTrigger value="craft" className="data-[state=active]:bg-cyan-600">
|
||||||
|
<Anvil className="w-4 h-4 mr-1" />
|
||||||
|
Craft
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="design" className="data-[state=active]:bg-amber-600">
|
||||||
|
<Scroll className="w-4 h-4 mr-1" />
|
||||||
|
Design
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="prepare" className="data-[state=active]:bg-amber-600">
|
||||||
|
<Hammer className="w-4 h-4 mr-1" />
|
||||||
|
Prepare
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="apply" className="data-[state=active]:bg-amber-600">
|
||||||
|
<Sparkles className="w-4 h-4 mr-1" />
|
||||||
|
Apply
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="craft" className="mt-4">
|
||||||
|
{renderCraftStage()}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="design" className="mt-4">
|
||||||
|
{renderDesignStage()}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="prepare" className="mt-4">
|
||||||
|
{renderPrepareStage()}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="apply" className="mt-4">
|
||||||
|
{renderApplyStage()}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Current Activity Indicator */}
|
||||||
|
{currentAction === 'craft' && equipmentCraftingProgress && (
|
||||||
|
<Card className="bg-cyan-900/30 border-cyan-600">
|
||||||
|
<CardContent className="py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Anvil className="w-5 h-5 text-cyan-400" />
|
||||||
|
<span>Crafting equipment...</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
{((equipmentCraftingProgress.progress / equipmentCraftingProgress.required) * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentAction === 'design' && designProgress && (
|
||||||
|
<Card className="bg-purple-900/30 border-purple-600">
|
||||||
|
<CardContent className="py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Scroll className="w-5 h-5 text-purple-400" />
|
||||||
|
<span>Designing enchantment...</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
{((designProgress.progress / designProgress.required) * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentAction === 'prepare' && preparationProgress && (
|
||||||
|
<Card className="bg-blue-900/30 border-blue-600">
|
||||||
|
<CardContent className="py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Hammer className="w-5 h-5 text-blue-400" />
|
||||||
|
<span>Preparing equipment...</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
{((preparationProgress.progress / preparationProgress.required) * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentAction === 'enchant' && applicationProgress && (
|
||||||
|
<Card className="bg-amber-900/30 border-amber-600">
|
||||||
|
<CardContent className="py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-amber-400" />
|
||||||
|
<span>{applicationProgress.paused ? 'Enchantment paused' : 'Applying enchantment...'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
{((applicationProgress.progress / applicationProgress.required) * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
{applicationProgress.paused ? (
|
||||||
|
<Button size="sm" onClick={resumeApplication}>Resume</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={pauseApplication}>Pause</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
700
src/components/game/tabs/DebugTab.tsx
Executable file
700
src/components/game/tabs/DebugTab.tsx
Executable file
@@ -0,0 +1,700 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
RotateCcw, Bug, Plus, Minus, Lock, Unlock, Zap,
|
||||||
|
Clock, Star, AlertTriangle, Sparkles, Settings, Eye, BookOpen
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { GameStore } from '@/lib/game/types';
|
||||||
|
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
|
import { fmt } from '@/lib/game/store';
|
||||||
|
import { useDebug } from '@/lib/game/debug-context';
|
||||||
|
|
||||||
|
interface DebugTabProps {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DebugTab({ store }: DebugTabProps) {
|
||||||
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
const { showComponentNames, toggleComponentNames } = useDebug();
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (confirmReset) {
|
||||||
|
store.resetGame();
|
||||||
|
setConfirmReset(false);
|
||||||
|
} else {
|
||||||
|
setConfirmReset(true);
|
||||||
|
setTimeout(() => setConfirmReset(false), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddMana = (amount: number) => {
|
||||||
|
// Use gatherMana multiple times to add mana
|
||||||
|
for (let i = 0; i < amount; i++) {
|
||||||
|
store.gatherMana();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlockAttunement = (id: string) => {
|
||||||
|
// Debug action to unlock attunements
|
||||||
|
if (store.debugUnlockAttunement) {
|
||||||
|
store.debugUnlockAttunement(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlockElement = (element: string) => {
|
||||||
|
store.unlockElement(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddElementalMana = (element: string, amount: number) => {
|
||||||
|
const elem = store.elements[element];
|
||||||
|
if (elem?.unlocked) {
|
||||||
|
// Add directly to element pool - need to implement in store
|
||||||
|
if (store.debugAddElementalMana) {
|
||||||
|
store.debugAddElementalMana(element, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetTime = (day: number, hour: number) => {
|
||||||
|
if (store.debugSetTime) {
|
||||||
|
store.debugSetTime(day, hour);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAttunementXP = (id: string, amount: number) => {
|
||||||
|
if (store.debugAddAttunementXP) {
|
||||||
|
store.debugAddAttunementXP(id, amount);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Warning Banner */}
|
||||||
|
<Card className="bg-amber-900/20 border-amber-600/50">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-center gap-2 text-amber-400">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
<span className="font-semibold">Debug Mode</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-amber-300/70 mt-1">
|
||||||
|
These tools are for development and testing. Using them may break game balance or save data.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display Options */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
Display Options
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="show-component-names" className="text-sm">Show Component Names</Label>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Display component names at the top of each component for debugging
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="show-component-names"
|
||||||
|
checked={showComponentNames}
|
||||||
|
onCheckedChange={toggleComponentNames}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Game Reset */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-red-400 text-sm flex items-center gap-2">
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Game Reset
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Reset all game progress and start fresh. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className={`w-full ${confirmReset ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'}`}
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
{confirmReset ? (
|
||||||
|
<>
|
||||||
|
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||||
|
Click Again to Confirm Reset
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Reset Game
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Mana Debug */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-blue-400 text-sm flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
Mana Debug
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="text-xs text-gray-400 mb-2">
|
||||||
|
Current: {fmt(store.rawMana)} / {fmt(store.getMaxMana())}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleAddMana(10)}>
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> +10
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleAddMana(100)}>
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> +100
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleAddMana(1000)}>
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> +1K
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleAddMana(10000)}>
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> +10K
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
<div className="text-xs text-gray-400">Fill to max:</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||||
|
onClick={() => {
|
||||||
|
const max = store.getMaxMana();
|
||||||
|
const current = store.rawMana;
|
||||||
|
for (let i = 0; i < Math.floor(max - current); i++) {
|
||||||
|
store.gatherMana();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Fill Mana
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Time Control */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
Time Control
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
Current: Day {store.day}, Hour {store.hour}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleSetTime(1, 0)}>
|
||||||
|
Day 1
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleSetTime(10, 0)}>
|
||||||
|
Day 10
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleSetTime(20, 0)}>
|
||||||
|
Day 20
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleSetTime(30, 0)}>
|
||||||
|
Day 30
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => store.togglePause()}
|
||||||
|
>
|
||||||
|
{store.paused ? '▶ Resume' : '⏸ Pause'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Attunement Unlock */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Attunements
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
|
||||||
|
const isActive = store.attunements?.[id]?.active;
|
||||||
|
const level = store.attunements?.[id]?.level || 1;
|
||||||
|
const xp = store.attunements?.[id]?.experience || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={id} className="flex items-center justify-between p-2 bg-gray-800/50 rounded">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{def.icon}</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{def.name}</div>
|
||||||
|
{isActive && (
|
||||||
|
<div className="text-xs text-gray-400">Lv.{level} • {xp} XP</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{!isActive && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleUnlockAttunement(id)}
|
||||||
|
>
|
||||||
|
<Unlock className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAddAttunementXP(id, 50)}
|
||||||
|
>
|
||||||
|
+50 XP
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAddAttunementXP(id, 500)}
|
||||||
|
>
|
||||||
|
+500 XP
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Element Unlock */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-green-400 text-sm flex items-center gap-2">
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
Elemental Mana
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||||
|
{Object.entries(ELEMENTS).map(([id, def]) => {
|
||||||
|
const elem = store.elements[id];
|
||||||
|
const isUnlocked = elem?.unlocked;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={`p-2 rounded border ${
|
||||||
|
isUnlocked ? 'border-gray-600' : 'border-gray-800 opacity-60'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
borderColor: isUnlocked ? def.color : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span style={{ color: def.color }}>{def.sym}</span>
|
||||||
|
{!isUnlocked && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-5 w-5 p-0"
|
||||||
|
onClick={() => handleUnlockElement(id)}
|
||||||
|
>
|
||||||
|
<Lock className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: def.color }}>{def.name}</div>
|
||||||
|
{isUnlocked && (
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
{elem.current.toFixed(0)}/{elem.max}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isUnlocked && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-5 w-full mt-1 text-xs"
|
||||||
|
onClick={() => handleAddElementalMana(id, 100)}
|
||||||
|
>
|
||||||
|
+100
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Skills Debug */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
Quick Actions
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// Unlock all base elements
|
||||||
|
['fire', 'water', 'air', 'earth', 'light', 'dark', 'death'].forEach(e => {
|
||||||
|
if (!store.elements[e]?.unlocked) {
|
||||||
|
store.unlockElement(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unlock All Base Elements
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// Unlock utility elements (only transference remains)
|
||||||
|
['transference'].forEach(e => {
|
||||||
|
if (!store.elements[e]?.unlocked) {
|
||||||
|
store.unlockElement(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unlock Utility Elements
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// Max floor
|
||||||
|
if (store.debugSetFloor) {
|
||||||
|
store.debugSetFloor(100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip to Floor 100
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// Reset floor HP
|
||||||
|
if (store.resetFloorHP) {
|
||||||
|
store.resetFloorHP();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Floor HP
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Skill Research Debug */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
Skill Research Debug
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Enchanting Skills */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-400 mb-2">Enchanting Skills:</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// Level up all enchanting skills by 1
|
||||||
|
const enchantSkills = ['enchanting', 'efficientEnchant', 'disenchanting', 'enchantSpeed', 'scrollCrafting', 'essenceRefining'];
|
||||||
|
enchantSkills.forEach(skillId => {
|
||||||
|
if (store.skills[skillId] !== undefined) {
|
||||||
|
store.skills[skillId] = Math.min((store.skills[skillId] || 0) + 1, 10);
|
||||||
|
} else {
|
||||||
|
store.skills[skillId] = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+1 All Enchanting
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// Max all enchanting skills
|
||||||
|
const enchantSkills = ['enchanting', 'efficientEnchant', 'disenchanting', 'enchantSpeed', 'scrollCrafting', 'essenceRefining'];
|
||||||
|
enchantSkills.forEach(skillId => {
|
||||||
|
store.skills[skillId] = 10;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Max All Enchanting
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mana Skills */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-400 mb-2">Mana Skills:</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const manaSkills = ['manaWell', 'manaFlow', 'elemAttune', 'manaOverflow'];
|
||||||
|
manaSkills.forEach(skillId => {
|
||||||
|
if (store.skills[skillId] !== undefined) {
|
||||||
|
store.skills[skillId] = Math.min((store.skills[skillId] || 0) + 1, 10);
|
||||||
|
} else {
|
||||||
|
store.skills[skillId] = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+1 All Mana
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const manaSkills = ['manaWell', 'manaFlow', 'elemAttune', 'manaOverflow'];
|
||||||
|
manaSkills.forEach(skillId => {
|
||||||
|
store.skills[skillId] = 10;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Max All Mana
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Study Skills */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-400 mb-2">Study Skills:</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const studySkills = ['quickLearner', 'focusedMind', 'meditation', 'knowledgeRetention'];
|
||||||
|
studySkills.forEach(skillId => {
|
||||||
|
if (store.skills[skillId] !== undefined) {
|
||||||
|
store.skills[skillId] = Math.min((store.skills[skillId] || 0) + 1, 10);
|
||||||
|
} else {
|
||||||
|
store.skills[skillId] = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+1 All Study
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const studySkills = ['quickLearner', 'focusedMind', 'meditation', 'knowledgeRetention'];
|
||||||
|
studySkills.forEach(skillId => {
|
||||||
|
store.skills[skillId] = 10;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Max All Study
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Crafting Skills */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-400 mb-2">Crafting Skills:</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const craftSkills = ['effCrafting', 'fieldRepair', 'elemCrafting'];
|
||||||
|
craftSkills.forEach(skillId => {
|
||||||
|
if (store.skills[skillId] !== undefined) {
|
||||||
|
store.skills[skillId] = Math.min((store.skills[skillId] || 0) + 1, 10);
|
||||||
|
} else {
|
||||||
|
store.skills[skillId] = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+1 All Crafting
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const craftSkills = ['effCrafting', 'fieldRepair', 'elemCrafting'];
|
||||||
|
craftSkills.forEach(skillId => {
|
||||||
|
store.skills[skillId] = 10;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Max All Crafting
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Research Effects */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-400 mb-2">Research Effects:</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// Unlock all spell research
|
||||||
|
const researchSkills = [
|
||||||
|
'researchManaSpells', 'researchFireSpells', 'researchWaterSpells',
|
||||||
|
'researchAirSpells', 'researchEarthSpells', 'researchLightSpells',
|
||||||
|
'researchDarkSpells', 'researchLifeDeathSpells',
|
||||||
|
'researchAdvancedFire', 'researchAdvancedWater', 'researchAdvancedAir',
|
||||||
|
'researchAdvancedEarth', 'researchAdvancedLight', 'researchAdvancedDark',
|
||||||
|
'researchMasterFire', 'researchMasterWater', 'researchMasterEarth',
|
||||||
|
'researchDamageEffects', 'researchCombatEffects',
|
||||||
|
'researchManaEffects', 'researchAdvancedManaEffects', 'researchUtilityEffects'
|
||||||
|
];
|
||||||
|
researchSkills.forEach(skillId => {
|
||||||
|
store.skills[skillId] = 1;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unlock All Research
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// Add all unlocked effects to unlockedEffects
|
||||||
|
const effectIds = Object.keys(store.unlockedEffects || {});
|
||||||
|
// Add spell effects, mana effects, combat effects, utility effects
|
||||||
|
const allEffectIds = [
|
||||||
|
// Spell effects
|
||||||
|
'spell_manaBolt', 'spell_manaStrike', 'spell_fireball', 'spell_emberShot',
|
||||||
|
'spell_waterJet', 'spell_iceShard', 'spell_gust', 'spell_windSlash',
|
||||||
|
'spell_stoneBullet', 'spell_rockSpike', 'spell_lightLance', 'spell_radiance',
|
||||||
|
'spell_shadowBolt', 'spell_darkPulse', 'spell_drain',
|
||||||
|
// Tier 2 spells
|
||||||
|
'spell_inferno', 'spell_flameWave', 'spell_tidalWave', 'spell_iceStorm',
|
||||||
|
'spell_hurricane', 'spell_windBlade', 'spell_earthquake', 'spell_stoneBarrage',
|
||||||
|
'spell_solarFlare', 'spell_divineSmite', 'spell_voidRift', 'spell_shadowStorm',
|
||||||
|
// Tier 3 spells
|
||||||
|
'spell_pyroclasm', 'spell_tsunami', 'spell_meteorStrike',
|
||||||
|
// Lightning
|
||||||
|
'spell_spark', 'spell_lightningBolt', 'spell_chainLightning',
|
||||||
|
'spell_stormCall', 'spell_thunderStrike',
|
||||||
|
// Metal and Sand
|
||||||
|
'spell_metalShard', 'spell_ironFist', 'spell_steelTempest', 'spell_furnaceBlast',
|
||||||
|
'spell_sandBlast', 'spell_sandstorm', 'spell_desertWind', 'spell_duneCollapse',
|
||||||
|
// Mana effects
|
||||||
|
'mana_cap_50', 'mana_cap_100', 'mana_regen_1', 'mana_regen_2', 'mana_regen_5',
|
||||||
|
'click_mana_1', 'click_mana_3',
|
||||||
|
// Combat effects
|
||||||
|
'damage_5', 'damage_10', 'damage_pct_10', 'crit_5', 'attack_speed_10',
|
||||||
|
// Utility effects
|
||||||
|
'meditate_10', 'study_10', 'insight_5',
|
||||||
|
// Special
|
||||||
|
'spell_echo_10', 'guardian_dmg_10', 'overpower_80',
|
||||||
|
// Weapon mana
|
||||||
|
'weapon_mana_cap_20', 'weapon_mana_cap_50', 'weapon_mana_cap_100',
|
||||||
|
'weapon_mana_regen_1', 'weapon_mana_regen_2', 'weapon_mana_regen_5',
|
||||||
|
// Sword enchants
|
||||||
|
'sword_fire', 'sword_frost', 'sword_lightning', 'sword_void'
|
||||||
|
];
|
||||||
|
allEffectIds.forEach(id => {
|
||||||
|
if (!store.unlockedEffects.includes(id)) {
|
||||||
|
store.unlockedEffects.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unlock All Effects
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max All */}
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-purple-600 hover:bg-purple-700"
|
||||||
|
onClick={() => {
|
||||||
|
// Max all skills
|
||||||
|
Object.keys(store.skills).forEach(skillId => {
|
||||||
|
const current = store.skills[skillId] || 0;
|
||||||
|
if (current < 10) {
|
||||||
|
store.skills[skillId] = 10;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Unlock all effects
|
||||||
|
const allEffectIds = [
|
||||||
|
'spell_manaBolt', 'spell_manaStrike', 'spell_fireball', 'spell_emberShot',
|
||||||
|
'spell_waterJet', 'spell_iceShard', 'spell_gust', 'spell_windSlash',
|
||||||
|
'spell_stoneBullet', 'spell_rockSpike', 'spell_lightLance', 'spell_radiance',
|
||||||
|
'spell_shadowBolt', 'spell_darkPulse', 'spell_drain', 'spell_inferno',
|
||||||
|
'spell_flameWave', 'spell_tidalWave', 'spell_iceStorm', 'spell_hurricane',
|
||||||
|
'spell_windBlade', 'spell_earthquake', 'spell_stoneBarrage', 'spell_solarFlare',
|
||||||
|
'spell_divineSmite', 'spell_voidRift', 'spell_shadowStorm', 'spell_pyroclasm',
|
||||||
|
'spell_tsunami', 'spell_meteorStrike', 'spell_spark', 'spell_lightningBolt',
|
||||||
|
'spell_chainLightning', 'spell_stormCall', 'spell_thunderStrike', 'spell_metalShard',
|
||||||
|
'spell_ironFist', 'spell_steelTempest', 'spell_furnaceBlast', 'spell_sandBlast',
|
||||||
|
'spell_sandstorm', 'spell_desertWind', 'spell_duneCollapse',
|
||||||
|
'mana_cap_50', 'mana_cap_100', 'mana_regen_1', 'mana_regen_2', 'mana_regen_5',
|
||||||
|
'click_mana_1', 'click_mana_3', 'damage_5', 'damage_10', 'damage_pct_10',
|
||||||
|
'crit_5', 'attack_speed_10', 'meditate_10', 'study_10', 'insight_5',
|
||||||
|
'spell_echo_10', 'guardian_dmg_10', 'overpower_80',
|
||||||
|
'weapon_mana_cap_20', 'weapon_mana_cap_50', 'weapon_mana_cap_100',
|
||||||
|
'weapon_mana_regen_1', 'weapon_mana_regen_2', 'weapon_mana_regen_5',
|
||||||
|
'sword_fire', 'sword_frost', 'sword_lightning', 'sword_void'
|
||||||
|
];
|
||||||
|
allEffectIds.forEach(id => {
|
||||||
|
if (!store.unlockedEffects.includes(id)) {
|
||||||
|
store.unlockedEffects.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🚀 Max Everything
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
393
src/components/game/tabs/EquipmentTab.tsx
Executable file
393
src/components/game/tabs/EquipmentTab.tsx
Executable file
@@ -0,0 +1,393 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
EQUIPMENT_TYPES,
|
||||||
|
EQUIPMENT_SLOTS,
|
||||||
|
getEquipmentBySlot,
|
||||||
|
type EquipmentSlot,
|
||||||
|
type EquipmentType,
|
||||||
|
} from '@/lib/game/data/equipment';
|
||||||
|
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||||
|
import { fmt } from '@/lib/game/store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { GameStore, EquipmentInstance } from '@/lib/game/types';
|
||||||
|
|
||||||
|
export interface EquipmentTabProps {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slot display names
|
||||||
|
const SLOT_NAMES: Record<EquipmentSlot, string> = {
|
||||||
|
mainHand: 'Main Hand',
|
||||||
|
offHand: 'Off Hand',
|
||||||
|
head: 'Head',
|
||||||
|
body: 'Body',
|
||||||
|
hands: 'Hands',
|
||||||
|
feet: 'Feet',
|
||||||
|
accessory1: 'Accessory 1',
|
||||||
|
accessory2: 'Accessory 2',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slot icons
|
||||||
|
const SLOT_ICONS: Record<EquipmentSlot, string> = {
|
||||||
|
mainHand: '⚔️',
|
||||||
|
offHand: '🛡️',
|
||||||
|
head: '🎩',
|
||||||
|
body: '👕',
|
||||||
|
hands: '🧤',
|
||||||
|
feet: '👢',
|
||||||
|
accessory1: '💍',
|
||||||
|
accessory2: '📿',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rarity colors
|
||||||
|
const RARITY_COLORS: Record<string, string> = {
|
||||||
|
common: 'border-gray-500 bg-gray-800/30',
|
||||||
|
uncommon: 'border-green-500 bg-green-900/20',
|
||||||
|
rare: 'border-blue-500 bg-blue-900/20',
|
||||||
|
epic: 'border-purple-500 bg-purple-900/20',
|
||||||
|
legendary: 'border-amber-500 bg-amber-900/20',
|
||||||
|
mythic: 'border-red-500 bg-red-900/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RARITY_TEXT_COLORS: Record<string, string> = {
|
||||||
|
common: 'text-gray-300',
|
||||||
|
uncommon: 'text-green-400',
|
||||||
|
rare: 'text-blue-400',
|
||||||
|
epic: 'text-purple-400',
|
||||||
|
legendary: 'text-amber-400',
|
||||||
|
mythic: 'text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EquipmentTab({ store }: EquipmentTabProps) {
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null);
|
||||||
|
|
||||||
|
// Get unequipped items
|
||||||
|
const equippedIds = new Set(Object.values(store.equippedInstances).filter(Boolean));
|
||||||
|
const unequippedItems = Object.values(store.equipmentInstances).filter(
|
||||||
|
(inst) => !equippedIds.has(inst.instanceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Equip an item to a slot
|
||||||
|
const handleEquip = (instanceId: string, slot: EquipmentSlot) => {
|
||||||
|
store.equipItem(instanceId, slot);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unequip from a slot
|
||||||
|
const handleUnequip = (slot: EquipmentSlot) => {
|
||||||
|
store.unequipItem(slot);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get items that can be equipped in a slot
|
||||||
|
const getEquippableItems = (slot: EquipmentSlot): EquipmentInstance[] => {
|
||||||
|
const equipmentTypes = getEquipmentBySlot(slot);
|
||||||
|
const typeIds = new Set(equipmentTypes.map((t) => t.id));
|
||||||
|
|
||||||
|
return unequippedItems.filter((inst) => typeIds.has(inst.typeId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all items that can go in a slot (including accessories that can go in either accessory slot)
|
||||||
|
const getItemsForSlot = (slot: EquipmentSlot): EquipmentInstance[] => {
|
||||||
|
if (slot === 'accessory1' || slot === 'accessory2') {
|
||||||
|
// Accessories can go in either slot
|
||||||
|
const accessoryTypes = EQUIPMENT_TYPES;
|
||||||
|
const accessoryTypeIds = Object.values(accessoryTypes)
|
||||||
|
.filter((t) => t.category === 'accessory')
|
||||||
|
.map((t) => t.id);
|
||||||
|
|
||||||
|
return unequippedItems.filter((inst) => accessoryTypeIds.includes(inst.typeId));
|
||||||
|
}
|
||||||
|
return getEquippableItems(slot);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Equipment Slots */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||||
|
Equipped Gear
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{EQUIPMENT_SLOTS.map((slot) => {
|
||||||
|
const instanceId = store.equippedInstances[slot];
|
||||||
|
const instance = instanceId ? store.equipmentInstances[instanceId] : null;
|
||||||
|
const equipmentType = instance ? EQUIPMENT_TYPES[instance.typeId] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slot}
|
||||||
|
className={`p-3 rounded border ${
|
||||||
|
instance
|
||||||
|
? RARITY_COLORS[instance.rarity]
|
||||||
|
: 'border-gray-700 bg-gray-800/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{SLOT_ICONS[slot]}</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-300">
|
||||||
|
{SLOT_NAMES[slot]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{instance && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||||
|
onClick={() => handleUnequip(slot)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{instance ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity]}`}>
|
||||||
|
{instance.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
|
||||||
|
</div>
|
||||||
|
{instance.enchantments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{instance.enchantments.map((ench, i) => {
|
||||||
|
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
|
||||||
|
return (
|
||||||
|
<TooltipProvider key={i}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs cursor-help"
|
||||||
|
>
|
||||||
|
{effect?.name || ench.effectId}
|
||||||
|
{ench.stacks > 1 && ` x${ench.stacks}`}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{effect?.description || 'Unknown effect'}</p>
|
||||||
|
<p className="text-gray-400 text-xs">
|
||||||
|
Category: {effect?.category || 'unknown'}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500 italic">
|
||||||
|
Empty
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Inventory */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||||
|
Equipment Inventory ({unequippedItems.length} items)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{unequippedItems.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-sm text-center py-4">
|
||||||
|
No unequipped items. Craft new gear in the Crafting tab.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-96 overflow-y-auto">
|
||||||
|
{unequippedItems.map((instance) => {
|
||||||
|
const equipmentType = EQUIPMENT_TYPES[instance.typeId];
|
||||||
|
const validSlots = equipmentType
|
||||||
|
? (equipmentType.category === 'accessory'
|
||||||
|
? ['accessory1', 'accessory2'] as EquipmentSlot[]
|
||||||
|
: [equipmentType.slot])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instance.instanceId}
|
||||||
|
className={`p-3 rounded border ${RARITY_COLORS[instance.rarity]}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity]}`}>
|
||||||
|
{instance.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{equipmentType?.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{equipmentType?.category || 'unknown'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-400 space-y-1 mb-2">
|
||||||
|
<div>
|
||||||
|
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
|
||||||
|
{instance.quality < 100 && (
|
||||||
|
<span className="text-yellow-500 ml-1">
|
||||||
|
(Quality: {instance.quality}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{instance.enchantments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{instance.enchantments.map((ench, i) => {
|
||||||
|
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={i}
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{effect?.name || ench.effectId}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validSlots.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleEquip(instance.instanceId, value as EquipmentSlot)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Equip to..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{validSlots.map((slot) => (
|
||||||
|
<SelectItem
|
||||||
|
key={slot}
|
||||||
|
value={slot}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{SLOT_ICONS[slot]} {SLOT_NAMES[slot]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||||
|
onClick={() => store.deleteEquipmentInstance(instance.instanceId)}
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Delete this item</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Equipment Stats Summary */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||||
|
Equipment Stats Summary
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-amber-400 game-mono">
|
||||||
|
{Object.values(store.equipmentInstances).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Items</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-400 game-mono">
|
||||||
|
{equippedIds.size}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Equipped</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-400 game-mono">
|
||||||
|
{unequippedItems.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">In Inventory</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-purple-400 game-mono">
|
||||||
|
{Object.values(store.equipmentInstances).reduce(
|
||||||
|
(sum, inst) => sum + inst.enchantments.length,
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Enchantments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Effects from Equipment */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-sm text-gray-400 mb-2">Active Effects from Equipment:</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(() => {
|
||||||
|
const effects = store.getEquipmentEffects();
|
||||||
|
const effectEntries = Object.entries(effects).filter(([, v]) => v > 0);
|
||||||
|
|
||||||
|
if (effectEntries.length === 0) {
|
||||||
|
return <span className="text-gray-500 text-sm">No active effects</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectEntries.map(([stat, value]) => (
|
||||||
|
<Badge key={stat} variant="outline" className="text-xs">
|
||||||
|
{stat}: +{fmt(value)}
|
||||||
|
</Badge>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
582
src/components/game/tabs/FamiliarTab.tsx
Executable file
582
src/components/game/tabs/FamiliarTab.tsx
Executable file
@@ -0,0 +1,582 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Sparkles, Heart, Zap, Star, Shield, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull,
|
||||||
|
Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, CircleDot, Circle,
|
||||||
|
Sword, Wand2, ShieldCheck, TrendingUp, Clock, Crown
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { GameState, FamiliarInstance, FamiliarDef, FamiliarAbilityType } from '@/lib/game/types';
|
||||||
|
import { FAMILIARS_DEF, getFamiliarXpRequired, getFamiliarAbilityValue } from '@/lib/game/data/familiars';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
|
|
||||||
|
// Element icon mapping
|
||||||
|
const ELEMENT_ICONS: Record<string, typeof Flame> = {
|
||||||
|
fire: Flame,
|
||||||
|
water: Droplet,
|
||||||
|
air: Wind,
|
||||||
|
earth: Mountain,
|
||||||
|
light: Sun,
|
||||||
|
dark: Moon,
|
||||||
|
life: Leaf,
|
||||||
|
death: Skull,
|
||||||
|
mental: Brain,
|
||||||
|
transference: Link,
|
||||||
|
force: Force,
|
||||||
|
blood: Droplets,
|
||||||
|
metal: Shield,
|
||||||
|
wood: TreeDeciduous,
|
||||||
|
sand: Hourglass,
|
||||||
|
crystal: Gem,
|
||||||
|
stellar: Star,
|
||||||
|
void: CircleDot,
|
||||||
|
raw: Circle,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rarity colors
|
||||||
|
const RARITY_COLORS: Record<string, string> = {
|
||||||
|
common: 'text-gray-400 border-gray-600',
|
||||||
|
uncommon: 'text-green-400 border-green-600',
|
||||||
|
rare: 'text-blue-400 border-blue-600',
|
||||||
|
epic: 'text-purple-400 border-purple-600',
|
||||||
|
legendary: 'text-amber-400 border-amber-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RARITY_BG: Record<string, string> = {
|
||||||
|
common: 'bg-gray-900/50',
|
||||||
|
uncommon: 'bg-green-900/20',
|
||||||
|
rare: 'bg-blue-900/20',
|
||||||
|
epic: 'bg-purple-900/20',
|
||||||
|
legendary: 'bg-amber-900/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Role icons
|
||||||
|
const ROLE_ICONS: Record<string, typeof Sword> = {
|
||||||
|
combat: Sword,
|
||||||
|
mana: Sparkles,
|
||||||
|
support: Heart,
|
||||||
|
guardian: ShieldCheck,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ability type icons
|
||||||
|
const ABILITY_ICONS: Record<FamiliarAbilityType, typeof Zap> = {
|
||||||
|
damageBonus: Sword,
|
||||||
|
manaRegen: Sparkles,
|
||||||
|
autoGather: Zap,
|
||||||
|
critChance: Star,
|
||||||
|
castSpeed: Clock,
|
||||||
|
manaShield: Shield,
|
||||||
|
elementalBonus: Flame,
|
||||||
|
lifeSteal: Heart,
|
||||||
|
bonusGold: TrendingUp,
|
||||||
|
autoConvert: Wand2,
|
||||||
|
thorns: ShieldCheck,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FamiliarTabProps {
|
||||||
|
store: GameState & {
|
||||||
|
setActiveFamiliar: (index: number, active: boolean) => void;
|
||||||
|
setFamiliarNickname: (index: number, nickname: string) => void;
|
||||||
|
summonFamiliar: (familiarId: string) => void;
|
||||||
|
upgradeFamiliarAbility: (index: number, abilityType: FamiliarAbilityType) => void;
|
||||||
|
getActiveFamiliarBonuses: () => ReturnType<typeof import('@/lib/game/familiar-slice').createFamiliarSlice>['getActiveFamiliarBonuses'] extends () => infer R ? R : never;
|
||||||
|
getAvailableFamiliars: () => string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FamiliarTab({ store }: FamiliarTabProps) {
|
||||||
|
const [selectedFamiliar, setSelectedFamiliar] = useState<number | null>(null);
|
||||||
|
const [nicknameInput, setNicknameInput] = useState('');
|
||||||
|
|
||||||
|
const familiars = store.familiars;
|
||||||
|
const activeFamiliarSlots = store.activeFamiliarSlots;
|
||||||
|
const activeCount = familiars.filter(f => f.active).length;
|
||||||
|
const availableFamiliars = store.getAvailableFamiliars();
|
||||||
|
const familiarBonuses = store.getActiveFamiliarBonuses();
|
||||||
|
|
||||||
|
// Format XP display
|
||||||
|
const formatXp = (current: number, level: number) => {
|
||||||
|
const required = getFamiliarXpRequired(level);
|
||||||
|
return `${current}/${required}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get familiar definition
|
||||||
|
const getFamiliarDef = (instance: FamiliarInstance): FamiliarDef | undefined => {
|
||||||
|
return FAMILIARS_DEF[instance.familiarId];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render a single familiar card
|
||||||
|
const renderFamiliarCard = (instance: FamiliarInstance, index: number) => {
|
||||||
|
const def = getFamiliarDef(instance);
|
||||||
|
if (!def) return null;
|
||||||
|
|
||||||
|
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||||
|
const RoleIcon = ROLE_ICONS[def.role] || Sparkles;
|
||||||
|
const xpRequired = getFamiliarXpRequired(instance.level);
|
||||||
|
const xpPercent = Math.min(100, (instance.experience / xpRequired) * 100);
|
||||||
|
const bondPercent = instance.bond;
|
||||||
|
const isSelected = selectedFamiliar === index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={`${instance.familiarId}-${index}`}
|
||||||
|
className={`cursor-pointer transition-all ${RARITY_BG[def.rarity]} ${
|
||||||
|
isSelected ? 'ring-2 ring-amber-500' : ''
|
||||||
|
} ${instance.active ? 'ring-1 ring-green-500/50' : ''} border ${RARITY_COLORS[def.rarity]}`}
|
||||||
|
onClick={() => setSelectedFamiliar(isSelected ? null : index)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-black/30 flex items-center justify-center">
|
||||||
|
<ElementIcon className="w-6 h-6" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className={`text-sm ${RARITY_COLORS[def.rarity]}`}>
|
||||||
|
{instance.nickname || def.name}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-400">
|
||||||
|
<RoleIcon className="w-3 h-3" />
|
||||||
|
<span>Lv.{instance.level}</span>
|
||||||
|
{instance.active && (
|
||||||
|
<Badge className="ml-1 bg-green-900/50 text-green-300 text-xs py-0">Active</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className={`${RARITY_COLORS[def.rarity]} text-xs`}>
|
||||||
|
{def.rarity}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{/* XP Bar */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>XP</span>
|
||||||
|
<span>{formatXp(instance.experience, instance.level)}</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={xpPercent} className="h-1.5 bg-gray-800" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bond Bar */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Heart className="w-3 h-3" /> Bond
|
||||||
|
</span>
|
||||||
|
<span>{bondPercent.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={bondPercent} className="h-1.5 bg-gray-800" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Abilities Preview */}
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{instance.abilities.slice(0, 3).map(ability => {
|
||||||
|
const abilityDef = def.abilities.find(a => a.type === ability.type);
|
||||||
|
if (!abilityDef) return null;
|
||||||
|
const AbilityIcon = ABILITY_ICONS[ability.type] || Zap;
|
||||||
|
const value = getFamiliarAbilityValue(abilityDef, instance.level, ability.level);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={ability.type}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant="outline" className="text-xs py-0 flex items-center gap-1">
|
||||||
|
<AbilityIcon className="w-3 h-3" />
|
||||||
|
{ability.type === 'damageBonus' || ability.type === 'elementalBonus' ||
|
||||||
|
ability.type === 'castSpeed' || ability.type === 'critChance' ||
|
||||||
|
ability.type === 'lifeSteal' || ability.type === 'thorns' ||
|
||||||
|
ability.type === 'bonusGold'
|
||||||
|
? `+${value.toFixed(1)}%`
|
||||||
|
: `+${value.toFixed(1)}`}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="text-sm">{abilityDef.desc}</p>
|
||||||
|
<p className="text-xs text-gray-400">Level {ability.level}/10</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render selected familiar details
|
||||||
|
const renderFamiliarDetails = () => {
|
||||||
|
if (selectedFamiliar === null || selectedFamiliar >= familiars.length) return null;
|
||||||
|
|
||||||
|
const instance = familiars[selectedFamiliar];
|
||||||
|
const def = getFamiliarDef(instance);
|
||||||
|
if (!def) return null;
|
||||||
|
|
||||||
|
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">Familiar Details</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => setSelectedFamiliar(null)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Name and nickname */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-black/30 flex items-center justify-center">
|
||||||
|
<ElementIcon className="w-8 h-8" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-lg font-bold ${RARITY_COLORS[def.rarity]}`}>
|
||||||
|
{def.name}
|
||||||
|
</h3>
|
||||||
|
{instance.nickname && (
|
||||||
|
<p className="text-sm text-gray-400">"{instance.nickname}"</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nickname input */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={nicknameInput}
|
||||||
|
onChange={(e) => setNicknameInput(e.target.value)}
|
||||||
|
placeholder="Set nickname..."
|
||||||
|
className="h-8 text-sm bg-gray-800 border-gray-600"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
store.setFamiliarNickname(selectedFamiliar, nicknameInput);
|
||||||
|
setNicknameInput('');
|
||||||
|
}}
|
||||||
|
disabled={!nicknameInput.trim()}
|
||||||
|
>
|
||||||
|
Set
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="text-sm text-gray-300 italic">
|
||||||
|
{def.desc}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Level:</span>
|
||||||
|
<span className="text-white">{instance.level}/100</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Bond:</span>
|
||||||
|
<span className="text-white">{instance.bond.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Role:</span>
|
||||||
|
<span className="text-white capitalize">{def.role}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Element:</span>
|
||||||
|
<span style={{ color: ELEMENTS[def.element]?.color }}>{def.element}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
|
{/* Abilities */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-300">Abilities</h4>
|
||||||
|
{instance.abilities.map(ability => {
|
||||||
|
const abilityDef = def.abilities.find(a => a.type === ability.type);
|
||||||
|
if (!abilityDef) return null;
|
||||||
|
const AbilityIcon = ABILITY_ICONS[ability.type] || Zap;
|
||||||
|
const value = getFamiliarAbilityValue(abilityDef, instance.level, ability.level);
|
||||||
|
const upgradeCost = ability.level * 100;
|
||||||
|
const canUpgrade = instance.experience >= upgradeCost && ability.level < 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={ability.type} className="p-2 rounded bg-gray-800/50 border border-gray-700">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AbilityIcon className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="text-sm font-medium capitalize">
|
||||||
|
{ability.type.replace(/([A-Z])/g, ' $1').trim()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs">Lv.{ability.level}/10</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mb-2">{abilityDef.desc}</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-green-400">
|
||||||
|
Current: +{value.toFixed(2)}
|
||||||
|
{ability.type === 'damageBonus' || ability.type === 'elementalBonus' ||
|
||||||
|
ability.type === 'castSpeed' || ability.type === 'critChance' ||
|
||||||
|
ability.type === 'lifeSteal' || ability.type === 'thorns' ||
|
||||||
|
ability.type === 'bonusGold' ? '%' : ''}
|
||||||
|
</span>
|
||||||
|
{ability.level < 10 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
disabled={!canUpgrade}
|
||||||
|
onClick={() => store.upgradeFamiliarAbility(selectedFamiliar, ability.type)}
|
||||||
|
>
|
||||||
|
Upgrade ({upgradeCost} XP)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activate/Deactivate */}
|
||||||
|
<Button
|
||||||
|
className={`w-full ${instance.active ? 'bg-red-900/50 hover:bg-red-800/50' : 'bg-green-900/50 hover:bg-green-800/50'}`}
|
||||||
|
onClick={() => store.setActiveFamiliar(selectedFamiliar, !instance.active)}
|
||||||
|
disabled={!instance.active && activeCount >= activeFamiliarSlots}
|
||||||
|
>
|
||||||
|
{instance.active ? 'Deactivate' : 'Activate'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Flavor text */}
|
||||||
|
{def.flavorText && (
|
||||||
|
<p className="text-xs text-gray-500 italic text-center">
|
||||||
|
"{def.flavorText}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render summonable familiars
|
||||||
|
const renderSummonableFamiliars = () => {
|
||||||
|
if (availableFamiliars.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Available to Summon ({availableFamiliars.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-48">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableFamiliars.map(familiarId => {
|
||||||
|
const def = FAMILIARS_DEF[familiarId];
|
||||||
|
if (!def) return null;
|
||||||
|
|
||||||
|
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||||
|
const RoleIcon = ROLE_ICONS[def.role] || Sparkles;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={familiarId}
|
||||||
|
className={`p-2 rounded border ${RARITY_COLORS[def.rarity]} ${RARITY_BG[def.rarity]} flex items-center justify-between`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ElementIcon className="w-5 h-5" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||||
|
<div>
|
||||||
|
<div className={`text-sm font-medium ${RARITY_COLORS[def.rarity]}`}>
|
||||||
|
{def.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 flex items-center gap-1">
|
||||||
|
<RoleIcon className="w-3 h-3" />
|
||||||
|
{def.role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7"
|
||||||
|
onClick={() => store.summonFamiliar(familiarId)}
|
||||||
|
>
|
||||||
|
Summon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render active bonuses
|
||||||
|
const renderActiveBonuses = () => {
|
||||||
|
const hasBonuses = Object.entries(familiarBonuses).some(([key, value]) => {
|
||||||
|
if (key === 'damageMultiplier' || key === 'castSpeedMultiplier' ||
|
||||||
|
key === 'elementalDamageMultiplier' || key === 'insightMultiplier') {
|
||||||
|
return value > 1;
|
||||||
|
}
|
||||||
|
return value > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasBonuses) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-green-400 text-sm flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
Active Familiar Bonuses
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 text-sm">
|
||||||
|
{familiarBonuses.damageMultiplier > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sword className="w-4 h-4 text-red-400" />
|
||||||
|
<span>+{((familiarBonuses.damageMultiplier - 1) * 100).toFixed(0)}% DMG</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{familiarBonuses.manaRegenBonus > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-blue-400" />
|
||||||
|
<span>+{familiarBonuses.manaRegenBonus.toFixed(1)} regen</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{familiarBonuses.autoGatherRate > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4 text-yellow-400" />
|
||||||
|
<span>+{familiarBonuses.autoGatherRate.toFixed(1)}/hr gather</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{familiarBonuses.critChanceBonus > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Star className="w-4 h-4 text-amber-400" />
|
||||||
|
<span>+{familiarBonuses.critChanceBonus.toFixed(1)}% crit</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{familiarBonuses.castSpeedMultiplier > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>+{((familiarBonuses.castSpeedMultiplier - 1) * 100).toFixed(0)}% speed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{familiarBonuses.elementalDamageMultiplier > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Flame className="w-4 h-4 text-orange-400" />
|
||||||
|
<span>+{((familiarBonuses.elementalDamageMultiplier - 1) * 100).toFixed(0)}% elem</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{familiarBonuses.lifeStealPercent > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Heart className="w-4 h-4 text-red-400" />
|
||||||
|
<span>+{familiarBonuses.lifeStealPercent.toFixed(0)}% lifesteal</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{familiarBonuses.insightMultiplier > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-purple-400" />
|
||||||
|
<span>+{((familiarBonuses.insightMultiplier - 1) * 100).toFixed(0)}% insight</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Owned Familiars */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||||
|
<Heart className="w-4 h-4" />
|
||||||
|
Your Familiars ({familiars.length})
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
Active Slots: {activeCount}/{activeFamiliarSlots}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{familiars.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{familiars.map((instance, index) => renderFamiliarCard(instance, index))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-8 text-gray-500">
|
||||||
|
No familiars yet. Progress through the game to summon companions!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Bonuses */}
|
||||||
|
{renderActiveBonuses()}
|
||||||
|
|
||||||
|
{/* Selected Familiar Details */}
|
||||||
|
{renderFamiliarDetails()}
|
||||||
|
|
||||||
|
{/* Summonable Familiars */}
|
||||||
|
{renderSummonableFamiliars()}
|
||||||
|
|
||||||
|
{/* Familiar Guide */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
|
||||||
|
<Crown className="w-4 h-4" />
|
||||||
|
Familiar Guide
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm text-gray-300">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-amber-400 mb-1">Acquiring Familiars</h4>
|
||||||
|
<p>Familiars become available to summon as you progress through floors, gather mana, and sign pacts with guardians. Higher rarity familiars are unlocked later.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-amber-400 mb-1">Leveling & Bond</h4>
|
||||||
|
<p>Active familiars gain XP from combat, gathering, and time. Higher bond increases their power and XP gain. Upgrade abilities using XP to boost their effects.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-amber-400 mb-1">Roles</h4>
|
||||||
|
<p>
|
||||||
|
<span className="text-red-400">Combat</span> - Damage and crit bonuses<br/>
|
||||||
|
<span className="text-blue-400">Mana</span> - Regeneration and auto-gathering<br/>
|
||||||
|
<span className="text-green-400">Support</span> - Speed and utility<br/>
|
||||||
|
<span className="text-amber-400">Guardian</span> - Defense and shields
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-amber-400 mb-1">Active Slots</h4>
|
||||||
|
<p>You can have 1 familiar active by default. Upgrade through prestige to unlock more active slots for stacking bonuses.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
338
src/components/game/tabs/GolemancyTab.tsx
Executable file
338
src/components/game/tabs/GolemancyTab.tsx
Executable file
@@ -0,0 +1,338 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Mountain, Zap, Clock, Swords, Target, Sparkles, Lock, Check, X
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { GOLEMS_DEF, getGolemSlots, isGolemUnlocked, getGolemDamage, getGolemAttackSpeed, getGolemFloorDuration } from '@/lib/game/data/golems';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
|
import type { GameStore } from '@/lib/game/store';
|
||||||
|
|
||||||
|
export interface GolemancyTabProps {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GolemancyTab({ store }: GolemancyTabProps) {
|
||||||
|
const attunements = store.attunements;
|
||||||
|
const elements = store.elements;
|
||||||
|
const skills = store.skills;
|
||||||
|
const golemancy = store.golemancy;
|
||||||
|
const currentFloor = store.currentFloor;
|
||||||
|
const currentRoom = store.currentRoom;
|
||||||
|
const toggleGolem = store.toggleGolem;
|
||||||
|
|
||||||
|
// Get Fabricator level and golem slots
|
||||||
|
const fabricatorLevel = attunements.fabricator?.level || 0;
|
||||||
|
const fabricatorActive = attunements.fabricator?.active || false;
|
||||||
|
const maxSlots = getGolemSlots(fabricatorLevel);
|
||||||
|
|
||||||
|
// Get unlocked elements
|
||||||
|
const unlockedElements = Object.entries(elements)
|
||||||
|
.filter(([, e]) => e.unlocked)
|
||||||
|
.map(([id]) => id);
|
||||||
|
|
||||||
|
// Get all unlocked golems
|
||||||
|
const unlockedGolems = Object.values(GOLEMS_DEF).filter(golem =>
|
||||||
|
isGolemUnlocked(golem.id, attunements, unlockedElements)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if golemancy is available
|
||||||
|
const hasGolemancy = fabricatorActive && fabricatorLevel >= 2;
|
||||||
|
|
||||||
|
// Check if currently in combat (not puzzle)
|
||||||
|
const inCombat = currentRoom.roomType !== 'puzzle';
|
||||||
|
|
||||||
|
// Get element info helper
|
||||||
|
const getElementInfo = (elementId: string) => {
|
||||||
|
return ELEMENTS[elementId];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render a golem card
|
||||||
|
const renderGolemCard = (golemId: string, isUnlocked: boolean) => {
|
||||||
|
const golem = GOLEMS_DEF[golemId];
|
||||||
|
if (!golem) return null;
|
||||||
|
|
||||||
|
const isEnabled = golemancy.enabledGolems.includes(golemId);
|
||||||
|
const isSelected = golemancy.summonedGolems.some(g => g.golemId === golemId);
|
||||||
|
|
||||||
|
// Calculate effective stats
|
||||||
|
const damage = getGolemDamage(golemId, skills);
|
||||||
|
const attackSpeed = getGolemAttackSpeed(golemId, skills);
|
||||||
|
const floorDuration = getGolemFloorDuration(skills);
|
||||||
|
|
||||||
|
// Get element color
|
||||||
|
const primaryElement = getElementInfo(golem.baseManaType);
|
||||||
|
const elementColor = primaryElement?.color || '#888';
|
||||||
|
|
||||||
|
if (!isUnlocked) {
|
||||||
|
// Locked golem card
|
||||||
|
return (
|
||||||
|
<Card key={golemId} className="bg-gray-900/80 border-gray-700 opacity-50">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
<span className="text-gray-500">???</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-xs text-gray-500">
|
||||||
|
{golem.unlockCondition.type === 'attunement_level' && (
|
||||||
|
<div>Requires Fabricator Level {golem.unlockCondition.level}</div>
|
||||||
|
)}
|
||||||
|
{golem.unlockCondition.type === 'mana_unlocked' && (
|
||||||
|
<div>Requires {ELEMENTS[golem.unlockCondition.manaType || '']?.name || golem.unlockCondition.manaType} Mana</div>
|
||||||
|
)}
|
||||||
|
{golem.unlockCondition.type === 'dual_attunement' && (
|
||||||
|
<div>Requires Enchanter & Fabricator Level 5</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={golemId}
|
||||||
|
className={`bg-gray-900/80 border-2 transition-all cursor-pointer ${
|
||||||
|
isEnabled
|
||||||
|
? 'border-green-500 bg-green-900/10'
|
||||||
|
: 'border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleGolem(golemId)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mountain className="w-4 h-4" style={{ color: elementColor }} />
|
||||||
|
<span style={{ color: elementColor }}>{golem.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{golem.isAoe && (
|
||||||
|
<Badge variant="outline" className="text-xs">AOE {golem.aoeTargets}</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="text-xs">T{golem.tier}</Badge>
|
||||||
|
{isEnabled ? (
|
||||||
|
<Check className="w-4 h-4 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<X className="w-4 h-4 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="text-xs text-gray-400">{golem.description}</p>
|
||||||
|
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Swords className="w-3 h-3 text-red-400" />
|
||||||
|
<span className="text-gray-400">DMG:</span>
|
||||||
|
<span className="text-white">{damage}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Zap className="w-3 h-3 text-yellow-400" />
|
||||||
|
<span className="text-gray-400">Speed:</span>
|
||||||
|
<span className="text-white">{attackSpeed.toFixed(1)}/hr</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Target className="w-3 h-3 text-blue-400" />
|
||||||
|
<span className="text-gray-400">Pierce:</span>
|
||||||
|
<span className="text-white">{Math.floor(golem.armorPierce * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3 text-purple-400" />
|
||||||
|
<span className="text-gray-400">Duration:</span>
|
||||||
|
<span className="text-white">{floorDuration} floor(s)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
|
{/* Summon Cost */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Summon Cost:</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{golem.summonCost.map((cost, idx) => {
|
||||||
|
const elem = getElementInfo(cost.element || '');
|
||||||
|
const available = cost.type === 'raw'
|
||||||
|
? store.rawMana
|
||||||
|
: elements[cost.element || '']?.current || 0;
|
||||||
|
const canAfford = available >= cost.amount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={idx}
|
||||||
|
variant="outline"
|
||||||
|
className={`text-xs ${canAfford ? 'border-green-500' : 'border-red-500'}`}
|
||||||
|
style={{ borderColor: canAfford ? undefined : '#ef4444' }}
|
||||||
|
>
|
||||||
|
<span style={{ color: elem?.color }}>{elem?.sym || '💎'}</span>
|
||||||
|
{' '}{cost.amount}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Maintenance Cost */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Maintenance/hr:</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{golem.maintenanceCost.map((cost, idx) => {
|
||||||
|
const elem = getElementInfo(cost.element || '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge key={idx} variant="outline" className="text-xs">
|
||||||
|
<span style={{ color: elem?.color }}>{elem?.sym || '💎'}</span>
|
||||||
|
{' '}{cost.amount}/hr
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="mt-2 text-xs text-green-400 flex items-center gap-1">
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
Active on Floor {currentFloor}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Mountain className="w-5 h-5 text-amber-500" />
|
||||||
|
Golemancy
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!hasGolemancy ? (
|
||||||
|
<div className="text-center text-gray-400 py-4">
|
||||||
|
<Lock className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>Unlock the Fabricator attunement and reach Level 2 to summon golems.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-400">Golem Slots:</span>
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
<span className="text-amber-400">{golemancy.enabledGolems.length}</span>
|
||||||
|
<span className="text-gray-500"> / {maxSlots}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-400">Fabricator Level:</span>
|
||||||
|
<span className="text-sm font-semibold text-amber-400">{fabricatorLevel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-400">Floor Duration:</span>
|
||||||
|
<span className="text-sm font-semibold">{getGolemFloorDuration(skills)} floor(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-400">Status:</span>
|
||||||
|
<span className={`text-sm ${inCombat ? 'text-green-400' : 'text-yellow-400'}`}>
|
||||||
|
{inCombat ? '⚔️ Combat Active' : '🧩 Puzzle Room (No Golems)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Golems are automatically summoned at the start of each combat floor.
|
||||||
|
They cost mana to maintain and will be dismissed if you run out.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Golems */}
|
||||||
|
{hasGolemancy && golemancy.summonedGolems.length > 0 && (
|
||||||
|
<Card className="bg-gray-900/80 border-green-600">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm text-green-400 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Active Golems ({golemancy.summonedGolems.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{golemancy.summonedGolems.map(sg => {
|
||||||
|
const golem = GOLEMS_DEF[sg.golemId];
|
||||||
|
const elem = getElementInfo(golem?.baseManaType || '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge key={sg.golemId} variant="outline" className="text-sm py-1 px-2">
|
||||||
|
<Mountain className="w-3 h-3 mr-1" style={{ color: elem?.color }} />
|
||||||
|
{golem?.name}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Golem Selection */}
|
||||||
|
{hasGolemancy && (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Select Golems to Summon</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-96">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pr-4">
|
||||||
|
{/* Unlocked Golems */}
|
||||||
|
{unlockedGolems.map(golem => renderGolemCard(golem.id, true))}
|
||||||
|
|
||||||
|
{/* Locked Golems */}
|
||||||
|
{Object.values(GOLEMS_DEF)
|
||||||
|
.filter(g => !isGolemUnlocked(g.id, attunements, unlockedElements))
|
||||||
|
.map(golem => renderGolemCard(golem.id, false))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Golemancy Skills Info */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Golemancy Skills</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-xs text-gray-400 space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Golem Mastery:</span>
|
||||||
|
<span className="text-white">+{skills.golemMastery || 0}0% damage</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Golem Efficiency:</span>
|
||||||
|
<span className="text-white">+{(skills.golemEfficiency || 0) * 5}% attack speed</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Golem Longevity:</span>
|
||||||
|
<span className="text-white">+{skills.golemLongevity || 0} floor duration</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Golem Siphon:</span>
|
||||||
|
<span className="text-white">-{(skills.golemSiphon || 0) * 10}% maintenance</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
567
src/components/game/tabs/GrimoireTab.tsx
Executable file
567
src/components/game/tabs/GrimoireTab.tsx
Executable file
@@ -0,0 +1,567 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { RotateCcw, Save, Trash2, Star, Flame, Clock, AlertCircle } from 'lucide-react';
|
||||||
|
import { useGameContext } from '../GameContext';
|
||||||
|
import { GUARDIANS, PRESTIGE_DEF, SKILLS_DEF, ELEMENTS } from '@/lib/game/constants';
|
||||||
|
import { getTierMultiplier, getBaseSkillId } from '@/lib/game/skill-evolution';
|
||||||
|
import { fmt, fmtDec, getBoonBonuses } from '@/lib/game/stores';
|
||||||
|
import type { Memory } from '@/lib/game/types';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
export function GrimoireTab() {
|
||||||
|
const { store } = useGameContext();
|
||||||
|
const [showMemoryPicker, setShowMemoryPicker] = useState(false);
|
||||||
|
|
||||||
|
// Get all skills that can be saved to memory
|
||||||
|
const saveableSkills = useMemo(() => {
|
||||||
|
const skills: { skillId: string; level: number; tier: number; upgrades: string[]; name: string }[] = [];
|
||||||
|
|
||||||
|
for (const [skillId, level] of Object.entries(store.skills)) {
|
||||||
|
if (level && level > 0) {
|
||||||
|
const baseSkillId = getBaseSkillId(skillId);
|
||||||
|
const tier = store.skillTiers?.[baseSkillId] || 1;
|
||||||
|
const tieredSkillId = tier > 1 ? `${baseSkillId}_t${tier}` : baseSkillId;
|
||||||
|
const upgrades = store.skillUpgrades?.[tieredSkillId] || [];
|
||||||
|
const skillDef = SKILLS_DEF[baseSkillId];
|
||||||
|
|
||||||
|
if (skillId === baseSkillId || skillId.includes('_t')) {
|
||||||
|
const actualLevel = store.skills[tieredSkillId] || store.skills[baseSkillId] || 0;
|
||||||
|
if (actualLevel > 0) {
|
||||||
|
skills.push({
|
||||||
|
skillId: baseSkillId,
|
||||||
|
level: actualLevel,
|
||||||
|
tier,
|
||||||
|
upgrades,
|
||||||
|
name: skillDef?.name || baseSkillId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueSkills = new Map<string, typeof skills[0]>();
|
||||||
|
for (const skill of skills) {
|
||||||
|
const existing = uniqueSkills.get(skill.skillId);
|
||||||
|
if (!existing || skill.tier > existing.tier || (skill.tier === existing.tier && skill.level > existing.level)) {
|
||||||
|
uniqueSkills.set(skill.skillId, skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(uniqueSkills.values()).sort((a, b) => {
|
||||||
|
if (a.tier !== b.tier) return b.tier - a.tier;
|
||||||
|
if (a.level !== b.level) return b.level - a.level;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}, [store.skills, store.skillTiers, store.skillUpgrades]);
|
||||||
|
|
||||||
|
const isSkillInMemory = (skillId: string) => store.memories.some(m => m.skillId === skillId);
|
||||||
|
const canAddMore = store.memories.length < store.memorySlots;
|
||||||
|
|
||||||
|
const addToMemory = (skill: typeof saveableSkills[0]) => {
|
||||||
|
const memory: Memory = {
|
||||||
|
skillId: skill.skillId,
|
||||||
|
level: skill.level,
|
||||||
|
tier: skill.tier,
|
||||||
|
upgrades: skill.upgrades,
|
||||||
|
};
|
||||||
|
store.addMemory(memory);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate total boons from active pacts
|
||||||
|
const activeBoons = useMemo(() => {
|
||||||
|
return getBoonBonuses(store.signedPacts);
|
||||||
|
}, [store.signedPacts]);
|
||||||
|
|
||||||
|
// Check if player can sign a pact
|
||||||
|
const canSignPact = (floor: number) => {
|
||||||
|
const guardian = GUARDIANS[floor];
|
||||||
|
if (!guardian) return false;
|
||||||
|
if (!store.defeatedGuardians.includes(floor)) return false;
|
||||||
|
if (store.signedPacts.includes(floor)) return false;
|
||||||
|
if (store.signedPacts.length >= store.pactSlots) return false;
|
||||||
|
if (store.rawMana < guardian.pactCost) return false;
|
||||||
|
if (store.pactRitualFloor !== null) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get pact affinity bonus for display
|
||||||
|
const pactAffinityBonus = (store.prestigeUpgrades.pactAffinity || 0) * 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Current Status */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||||
|
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Current Insight</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Insight</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
|
||||||
|
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pact Slots & Active Ritual */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Flame className="w-4 h-4" />
|
||||||
|
Pact Slots ({store.signedPacts.length}/{store.pactSlots})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Active Ritual Progress */}
|
||||||
|
{store.pactRitualFloor !== null && (
|
||||||
|
<div className="mb-4 p-3 rounded border-2 border-amber-500/50 bg-amber-900/20">
|
||||||
|
{(() => {
|
||||||
|
const guardian = GUARDIANS[store.pactRitualFloor];
|
||||||
|
if (!guardian) return null;
|
||||||
|
const requiredTime = guardian.pactTime * (1 - (store.prestigeUpgrades.pactAffinity || 0) * 0.1);
|
||||||
|
const progress = Math.min(100, (store.pactRitualProgress / requiredTime) * 100);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-amber-300 font-semibold text-sm">Signing Pact with {guardian.name}</span>
|
||||||
|
<span className="text-xs text-gray-400">{fmtDec(progress, 1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-gray-700 rounded overflow-hidden mb-2">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-500 transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
<Clock className="w-3 h-3 inline mr-1" />
|
||||||
|
{fmtDec(store.pactRitualProgress, 1)}h / {fmtDec(requiredTime, 1)}h
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 text-xs border-red-500/50 text-red-400 hover:bg-red-900/20"
|
||||||
|
onClick={() => store.cancelPactRitual()}
|
||||||
|
>
|
||||||
|
Cancel Ritual
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Pacts */}
|
||||||
|
{store.signedPacts.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{store.signedPacts.map((floor) => {
|
||||||
|
const guardian = GUARDIANS[floor];
|
||||||
|
if (!guardian) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={floor}
|
||||||
|
className="p-3 rounded border"
|
||||||
|
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}15` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||||
|
{guardian.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Floor {floor} Guardian</div>
|
||||||
|
<div className="text-xs text-amber-300 mt-1 italic">"{guardian.uniquePerk}"</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
|
||||||
|
onClick={() => store.removePact(floor)}
|
||||||
|
title="Break Pact"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{guardian.boons.map((boon, idx) => (
|
||||||
|
<Badge key={idx} className="bg-gray-700/50 text-gray-200 text-xs">
|
||||||
|
{boon.desc}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-500 text-sm text-center py-4">
|
||||||
|
No active pacts. Defeat guardians and sign pacts to gain boons.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Available Guardians for Pacts */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
Available Guardians ({store.defeatedGuardians.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{store.defeatedGuardians.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-sm text-center py-4">
|
||||||
|
Defeat guardians in the Spire to make them available for pacts.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="space-y-2 pr-2">
|
||||||
|
{store.defeatedGuardians
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((floor) => {
|
||||||
|
const guardian = GUARDIANS[floor];
|
||||||
|
if (!guardian) return null;
|
||||||
|
const canSign = canSignPact(floor);
|
||||||
|
const notEnoughMana = store.rawMana < guardian.pactCost;
|
||||||
|
const atCapacity = store.signedPacts.length >= store.pactSlots;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={floor}
|
||||||
|
className="p-3 rounded border border-gray-700 bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||||
|
{guardian.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
Floor {floor} • {ELEMENTS[guardian.element]?.name || guardian.element}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-amber-300">{fmt(guardian.pactCost)} mana</div>
|
||||||
|
<div className="text-xs text-gray-400">{guardian.pactTime}h ritual</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-purple-300 italic mb-2">"{guardian.uniquePerk}"</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{guardian.boons.map((boon, idx) => (
|
||||||
|
<Badge key={idx} className="bg-gray-700/50 text-gray-200 text-xs">
|
||||||
|
{boon.desc}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={canSign ? 'default' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canSign}
|
||||||
|
onClick={() => store.startPactRitual(floor, store.rawMana)}
|
||||||
|
>
|
||||||
|
{atCapacity
|
||||||
|
? 'Pact Slots Full'
|
||||||
|
: notEnoughMana
|
||||||
|
? 'Not Enough Mana'
|
||||||
|
: store.pactRitualFloor !== null
|
||||||
|
? 'Ritual in Progress'
|
||||||
|
: 'Start Pact Ritual'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Memory Slots */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Memory Slots ({store.memories.length}/{store.memorySlots})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-xs text-gray-400 mb-3">
|
||||||
|
Skills saved to memory will retain their level, tier, and upgrades when you start a new loop.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Saved Memories */}
|
||||||
|
{store.memories.length > 0 ? (
|
||||||
|
<div className="space-y-1 mb-3">
|
||||||
|
{store.memories.map((memory) => {
|
||||||
|
const skillDef = SKILLS_DEF[memory.skillId];
|
||||||
|
const tierMult = getTierMultiplier(memory.tier > 1 ? `${memory.skillId}_t${memory.tier}` : memory.skillId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={memory.skillId}
|
||||||
|
className="flex items-center justify-between p-2 rounded border border-amber-600/30 bg-amber-900/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-sm text-amber-300">{skillDef?.name || memory.skillId}</span>
|
||||||
|
{memory.tier > 1 && (
|
||||||
|
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
|
||||||
|
T{memory.tier} ({tierMult}x)
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-purple-400 text-xs">Lv.{memory.level}</span>
|
||||||
|
{memory.upgrades.length > 0 && (
|
||||||
|
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
|
||||||
|
<Star className="w-3 h-3" />
|
||||||
|
{memory.upgrades.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
|
||||||
|
onClick={() => store.removeMemory(memory.skillId)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-500 text-sm mb-3 text-center py-2">
|
||||||
|
No memories saved. Add skills below.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Memory Button */}
|
||||||
|
{canAddMore && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setShowMemoryPicker(!showMemoryPicker)}
|
||||||
|
>
|
||||||
|
{showMemoryPicker ? 'Hide Skills' : 'Add Skill to Memory'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skill Picker */}
|
||||||
|
{showMemoryPicker && canAddMore && (
|
||||||
|
<ScrollArea className="h-48 mt-2">
|
||||||
|
<div className="space-y-1 pr-2">
|
||||||
|
{saveableSkills.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-xs text-center py-4">
|
||||||
|
No skills with progress to save
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
saveableSkills.map((skill) => {
|
||||||
|
const isInMemory = isSkillInMemory(skill.skillId);
|
||||||
|
const tierMult = getTierMultiplier(skill.tier > 1 ? `${skill.skillId}_t${skill.tier}` : skill.skillId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={skill.skillId}
|
||||||
|
className={`p-2 rounded border cursor-pointer transition-all ${
|
||||||
|
isInMemory
|
||||||
|
? 'border-amber-500 bg-amber-900/30 opacity-50'
|
||||||
|
: 'border-gray-700 bg-gray-800/50 hover:border-amber-500/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => !isInMemory && addToMemory(skill)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-sm">{skill.name}</span>
|
||||||
|
{skill.tier > 1 && (
|
||||||
|
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
|
||||||
|
T{skill.tier} ({tierMult}x)
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-purple-400 text-sm">Lv.{skill.level}</span>
|
||||||
|
{skill.upgrades.length > 0 && (
|
||||||
|
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
|
||||||
|
<Star className="w-3 h-3" />
|
||||||
|
{skill.upgrades.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Boons Summary */}
|
||||||
|
{store.signedPacts.length > 0 && (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Boons Summary</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
{activeBoons.maxMana > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Max Mana:</span>
|
||||||
|
<span className="text-blue-300">+{activeBoons.maxMana}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeBoons.manaRegen > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Mana Regen:</span>
|
||||||
|
<span className="text-blue-300">+{activeBoons.manaRegen}/h</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeBoons.castingSpeed > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Cast Speed:</span>
|
||||||
|
<span className="text-amber-300">+{activeBoons.castingSpeed}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeBoons.elementalDamage > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Elem. Damage:</span>
|
||||||
|
<span className="text-red-300">+{activeBoons.elementalDamage}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeBoons.rawDamage > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Raw Damage:</span>
|
||||||
|
<span className="text-red-300">+{activeBoons.rawDamage}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeBoons.critChance > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Crit Chance:</span>
|
||||||
|
<span className="text-yellow-300">+{activeBoons.critChance}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeBoons.critDamage > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Crit Damage:</span>
|
||||||
|
<span className="text-yellow-300">+{activeBoons.critDamage}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeBoons.spellEfficiency > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Spell Cost:</span>
|
||||||
|
<span className="text-green-300">-{activeBoons.spellEfficiency}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeBoons.manaGain > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Mana Gain:</span>
|
||||||
|
<span className="text-blue-300">+{activeBoons.manaGain}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeBoons.insightGain > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Insight Gain:</span>
|
||||||
|
<span className="text-purple-300">+{activeBoons.insightGain}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeBoons.studySpeed > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Study Speed:</span>
|
||||||
|
<span className="text-cyan-300">+{activeBoons.studySpeed}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeBoons.prestigeInsight > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Prestige Insight:</span>
|
||||||
|
<span className="text-purple-300">+{activeBoons.prestigeInsight}/loop</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prestige Upgrades */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Insight Upgrades (Permanent)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
|
||||||
|
const level = store.prestigeUpgrades[id] || 0;
|
||||||
|
const maxed = level >= def.max;
|
||||||
|
const canBuy = !maxed && store.insight >= def.cost;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={id} className="p-3 rounded border border-gray-700 bg-gray-800/50">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{level}/{def.max}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={canBuy ? 'default' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canBuy}
|
||||||
|
onClick={() => store.doPrestige(id)}
|
||||||
|
>
|
||||||
|
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Game Button */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-400">Reset All Progress</div>
|
||||||
|
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
|
||||||
|
store.resetGame();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/game/tabs/LabTab.tsx
Executable file
116
src/components/game/tabs/LabTab.tsx
Executable file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
|
|
||||||
|
interface LabTabProps {
|
||||||
|
store: {
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
skills: Record<string, number>;
|
||||||
|
craftComposite: (target: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabTab({ store }: LabTabProps) {
|
||||||
|
// Render elemental mana grid - only show elements with current > 0
|
||||||
|
const renderElementsGrid = () => (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
|
||||||
|
{Object.entries(store.elements)
|
||||||
|
.filter(([, state]) => state.unlocked && state.current > 0)
|
||||||
|
.map(([id, state]) => {
|
||||||
|
const def = ELEMENTS[id];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="p-2 rounded border border-gray-700 bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div className="text-lg text-center">{def?.sym}</div>
|
||||||
|
<div className="text-xs font-semibold text-center" style={{ color: def?.color }}>{def?.name}</div>
|
||||||
|
<div className="text-xs text-gray-400 game-mono text-center">{state.current}/{state.max}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render composite crafting
|
||||||
|
const renderCompositeCrafting = () => {
|
||||||
|
const compositeElements = Object.entries(ELEMENTS)
|
||||||
|
.filter(([, def]) => def.recipe)
|
||||||
|
.filter(([id]) => store.elements[id]?.unlocked);
|
||||||
|
|
||||||
|
if (compositeElements.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">Composite Crafting</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{compositeElements.map(([id, def]) => {
|
||||||
|
const recipe = def.recipe || [];
|
||||||
|
const canCraft = recipe.every(r => (store.elements[r]?.current || 0) >= 1);
|
||||||
|
const craftBonus = 1 + (store.skills.elemCrafting || 0) * 0.25;
|
||||||
|
const output = Math.floor(craftBonus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">{def.sym}</span>
|
||||||
|
<span className="text-sm" style={{ color: def.color }}>{def.name}</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
({recipe.map(r => ELEMENTS[r]?.sym).join(' + ')})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!canCraft}
|
||||||
|
onClick={() => store.craftComposite(id)}
|
||||||
|
>
|
||||||
|
Craft ({output})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if there are any unlocked elements with current > 0
|
||||||
|
const hasUnlockedElements = Object.values(store.elements).some(e => e.unlocked && e.current > 0);
|
||||||
|
|
||||||
|
if (!hasUnlockedElements) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
No elemental mana available. Gather or convert mana to see elemental pools.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Elemental Mana Display */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">Elemental Mana</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{renderElementsGrid()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Composite Crafting */}
|
||||||
|
{renderCompositeCrafting()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/game/tabs/LootTab.tsx
Executable file
46
src/components/game/tabs/LootTab.tsx
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { GameStore } from '@/lib/game/store';
|
||||||
|
import { LootInventoryDisplay } from '@/components/game/LootInventory';
|
||||||
|
|
||||||
|
export interface LootTabProps {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LootTab({ store }: LootTabProps) {
|
||||||
|
const inventory = store.lootInventory;
|
||||||
|
const elements = store.elements;
|
||||||
|
const equipmentInstances = store.equipmentInstances;
|
||||||
|
|
||||||
|
// Count items for badge
|
||||||
|
const materialCount = Object.values(inventory.materials).reduce((a, b) => a + b, 0);
|
||||||
|
const blueprintCount = inventory.blueprints.length;
|
||||||
|
const equipmentCount = Object.keys(equipmentInstances).length;
|
||||||
|
const totalItems = materialCount + blueprintCount + equipmentCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||||
|
💎 Loot Inventory
|
||||||
|
<Badge className="ml-auto bg-gray-800 text-gray-300">
|
||||||
|
{totalItems} items
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<LootInventoryDisplay
|
||||||
|
inventory={inventory}
|
||||||
|
elements={elements}
|
||||||
|
equipmentInstances={equipmentInstances}
|
||||||
|
onDeleteMaterial={store.deleteMaterial}
|
||||||
|
onDeleteEquipment={store.deleteEquipmentInstance}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
369
src/components/game/tabs/SkillsTab.tsx
Executable file
369
src/components/game/tabs/SkillsTab.tsx
Executable file
@@ -0,0 +1,369 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { SKILLS_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||||
|
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||||
|
import { getUnifiedEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
|
||||||
|
import { getAvailableSkillCategories } from '@/lib/game/data/attunements';
|
||||||
|
import { fmt, fmtDec } from '@/lib/game/store';
|
||||||
|
import { formatStudyTime } from '@/lib/game/formatting';
|
||||||
|
import type { SkillUpgradeChoice, GameStore } from '@/lib/game/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { StudyProgress } from './StudyProgress';
|
||||||
|
import { UpgradeDialog } from './UpgradeDialog';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface SkillsTabProps {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if skill has milestone available
|
||||||
|
function hasMilestoneUpgrade(
|
||||||
|
skillId: string,
|
||||||
|
level: number,
|
||||||
|
skillTiers: Record<string, number>,
|
||||||
|
skillUpgrades: Record<string, string[]>
|
||||||
|
): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null {
|
||||||
|
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||||
|
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||||
|
if (!path) return null;
|
||||||
|
|
||||||
|
// Check level 5 milestone
|
||||||
|
if (level >= 5) {
|
||||||
|
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, skillTiers);
|
||||||
|
const selected5 = (skillUpgrades[skillId] || []).filter(id => id.includes('_l5'));
|
||||||
|
if (upgrades5.length > 0 && selected5.length < 2) {
|
||||||
|
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check level 10 milestone
|
||||||
|
if (level >= 10) {
|
||||||
|
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, skillTiers);
|
||||||
|
const selected10 = (skillUpgrades[skillId] || []).filter(id => id.includes('_l10'));
|
||||||
|
if (upgrades10.length > 0 && selected10.length < 2) {
|
||||||
|
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillsTab({ store }: SkillsTabProps) {
|
||||||
|
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
|
||||||
|
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
|
||||||
|
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
|
||||||
|
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const studySpeedMult = getStudySpeedMultiplier(store.skills);
|
||||||
|
const upgradeEffects = getUnifiedEffects(store);
|
||||||
|
|
||||||
|
// Toggle category collapse
|
||||||
|
const toggleCategory = (categoryId: string) => {
|
||||||
|
setCollapsedCategories(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(categoryId)) {
|
||||||
|
newSet.delete(categoryId);
|
||||||
|
} else {
|
||||||
|
newSet.add(categoryId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get upgrade choices for dialog
|
||||||
|
const getUpgradeChoices = () => {
|
||||||
|
if (!upgradeDialogSkill) return { available: [] as SkillUpgradeChoice[], selected: [] as string[] };
|
||||||
|
return store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { available, selected: alreadySelected } = getUpgradeChoices();
|
||||||
|
|
||||||
|
// Toggle selection
|
||||||
|
const toggleUpgrade = (upgradeId: string) => {
|
||||||
|
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
|
||||||
|
if (currentSelections.includes(upgradeId)) {
|
||||||
|
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
|
||||||
|
} else if (currentSelections.length < 2) {
|
||||||
|
setPendingUpgradeSelections([...currentSelections, upgradeId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Commit selections and close
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
|
||||||
|
if (currentSelections.length === 2 && upgradeDialogSkill) {
|
||||||
|
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections, upgradeDialogMilestone);
|
||||||
|
}
|
||||||
|
setPendingUpgradeSelections([]);
|
||||||
|
setUpgradeDialogSkill(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel and close
|
||||||
|
const handleCancel = () => {
|
||||||
|
setPendingUpgradeSelections([]);
|
||||||
|
setUpgradeDialogSkill(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Upgrade Selection Dialog */}
|
||||||
|
<UpgradeDialog
|
||||||
|
open={!!upgradeDialogSkill}
|
||||||
|
skillId={upgradeDialogSkill}
|
||||||
|
milestone={upgradeDialogMilestone}
|
||||||
|
pendingSelections={pendingUpgradeSelections}
|
||||||
|
available={available}
|
||||||
|
alreadySelected={alreadySelected}
|
||||||
|
onToggle={toggleUpgrade}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setPendingUpgradeSelections([]);
|
||||||
|
setUpgradeDialogSkill(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Current Study Progress */}
|
||||||
|
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
|
||||||
|
<Card className="bg-gray-900/80 border-purple-600/50">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<StudyProgress
|
||||||
|
currentStudyTarget={store.currentStudyTarget}
|
||||||
|
skills={store.skills}
|
||||||
|
studySpeedMult={studySpeedMult}
|
||||||
|
cancelStudy={store.cancelStudy}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Get available skill categories based on attunements */}
|
||||||
|
{(() => {
|
||||||
|
const availableCategories = getAvailableSkillCategories(store.attunements || {});
|
||||||
|
|
||||||
|
return SKILL_CATEGORIES
|
||||||
|
.filter(cat => availableCategories.includes(cat.id))
|
||||||
|
.map((cat) => {
|
||||||
|
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
|
||||||
|
if (skillsInCat.length === 0) return null;
|
||||||
|
|
||||||
|
const isCollapsed = collapsedCategories.has(cat.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2 cursor-pointer" onClick={() => toggleCategory(cat.id)}>
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center justify-between">
|
||||||
|
<span>{cat.icon} {cat.name}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs">{skillsInCat.length} skills</Badge>
|
||||||
|
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{skillsInCat.map(([id, def]) => {
|
||||||
|
// Get tier info
|
||||||
|
const currentTier = store.skillTiers?.[id] || 1;
|
||||||
|
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
|
||||||
|
const tierMultiplier = getTierMultiplier(tieredSkillId);
|
||||||
|
|
||||||
|
// Get the actual level from the tiered skill
|
||||||
|
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
|
||||||
|
const maxed = level >= def.max;
|
||||||
|
|
||||||
|
// Check if studying this skill
|
||||||
|
const isStudying = (store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
|
||||||
|
|
||||||
|
// Get tier name for display
|
||||||
|
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find(t => t.tier === currentTier);
|
||||||
|
const skillDisplayName = tierDef?.name || def.name;
|
||||||
|
|
||||||
|
// Check prerequisites
|
||||||
|
let prereqMet = true;
|
||||||
|
if (def.req) {
|
||||||
|
for (const [r, rl] of Object.entries(def.req)) {
|
||||||
|
if ((store.skills[r] || 0) < rl) {
|
||||||
|
prereqMet = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply skill modifiers
|
||||||
|
const costMult = getStudyCostMultiplier(store.skills);
|
||||||
|
const speedMult = getStudySpeedMultiplier(store.skills);
|
||||||
|
const studyEffects = getUnifiedEffects(store);
|
||||||
|
const effectiveSpeedMult = speedMult * studyEffects.studySpeedMultiplier;
|
||||||
|
|
||||||
|
// Study time scales with tier
|
||||||
|
const tierStudyTime = def.studyTime * currentTier;
|
||||||
|
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
|
||||||
|
|
||||||
|
// Cost scales with tier
|
||||||
|
const baseCost = def.base * (level + 1) * currentTier;
|
||||||
|
const cost = Math.floor(baseCost * costMult);
|
||||||
|
|
||||||
|
// Can start studying?
|
||||||
|
const canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
|
||||||
|
|
||||||
|
// Check for milestone upgrades
|
||||||
|
const milestoneInfo = hasMilestoneUpgrade(tieredSkillId, level, store.skillTiers || {}, store.skillUpgrades);
|
||||||
|
|
||||||
|
// Check for tier up
|
||||||
|
const nextTierSkill = getNextTierSkill(tieredSkillId);
|
||||||
|
const canTierUp = maxed && nextTierSkill;
|
||||||
|
|
||||||
|
// Get selected upgrades
|
||||||
|
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
|
||||||
|
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
|
||||||
|
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
|
||||||
|
isStudying ? 'border-purple-500 bg-purple-900/20' :
|
||||||
|
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
|
||||||
|
'border-gray-700 bg-gray-800/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-sm">{skillDisplayName}</span>
|
||||||
|
{currentTier > 1 && (
|
||||||
|
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
|
||||||
|
)}
|
||||||
|
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
|
||||||
|
{selectedUpgrades.length > 0 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{selectedL5.length > 0 && (
|
||||||
|
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
|
||||||
|
)}
|
||||||
|
{selectedL10.length > 0 && (
|
||||||
|
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
|
||||||
|
{!prereqMet && def.req && (
|
||||||
|
<div className="text-xs text-red-400 mt-1">
|
||||||
|
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
|
||||||
|
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
|
||||||
|
</span>
|
||||||
|
{' • '}
|
||||||
|
<span className={costMult < 1 ? 'text-green-400' : ''}>
|
||||||
|
Cost: {fmt(cost)} mana{costMult < 1 && <span className="text-xs ml-1">({Math.round(costMult * 100)}% cost)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{milestoneInfo && (
|
||||||
|
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
|
||||||
|
⭐ Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
|
||||||
|
{/* Level dots */}
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
{Array.from({ length: def.max }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`w-2 h-2 rounded-full border ${
|
||||||
|
i < level ? 'bg-purple-500 border-purple-400' :
|
||||||
|
i === 4 || i === 9 ? 'border-amber-500' :
|
||||||
|
'border-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isStudying ? (
|
||||||
|
<div className="text-xs text-purple-400">
|
||||||
|
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
|
||||||
|
</div>
|
||||||
|
) : milestoneInfo ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
|
onClick={() => {
|
||||||
|
setUpgradeDialogSkill(tieredSkillId);
|
||||||
|
setUpgradeDialogMilestone(milestoneInfo.milestone);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Choose Upgrades
|
||||||
|
</Button>
|
||||||
|
) : canTierUp ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-purple-600 hover:bg-purple-700"
|
||||||
|
onClick={() => store.tierUpSkill(tieredSkillId)}
|
||||||
|
>
|
||||||
|
⬆️ Tier Up
|
||||||
|
</Button>
|
||||||
|
) : maxed ? (
|
||||||
|
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={canStudy ? 'default' : 'outline'}
|
||||||
|
disabled={!canStudy}
|
||||||
|
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||||||
|
onClick={() => store.startStudyingSkill(tieredSkillId)}
|
||||||
|
>
|
||||||
|
Study ({fmt(cost)})
|
||||||
|
</Button>
|
||||||
|
{/* Parallel Study button */}
|
||||||
|
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.PARALLEL_STUDY) &&
|
||||||
|
store.currentStudyTarget &&
|
||||||
|
!store.parallelStudyTarget &&
|
||||||
|
store.currentStudyTarget.id !== tieredSkillId &&
|
||||||
|
canStudy && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
|
||||||
|
onClick={() => store.startParallelStudySkill(tieredSkillId)}
|
||||||
|
>
|
||||||
|
⚡
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Study in parallel (50% speed)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/components/game/tabs/SpellsTab.tsx
Executable file
180
src/components/game/tabs/SpellsTab.tsx
Executable file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ELEMENTS, SPELLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||||
|
import { calcDamage, canAffordSpellCost, fmt } from '@/lib/game/store';
|
||||||
|
import { formatSpellCost, getSpellCostColor } from '@/lib/game/formatting';
|
||||||
|
|
||||||
|
interface SpellsTabProps {
|
||||||
|
store: {
|
||||||
|
spells: Record<string, { learned: boolean; level: number; studyProgress: number }>;
|
||||||
|
equippedInstances: Record<string, string | null>;
|
||||||
|
equipmentInstances: Record<string, { instanceId: string; name: string; enchantments: { effectId: string; stacks: number }[] }>;
|
||||||
|
activeSpell: string;
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
signedPacts: number[];
|
||||||
|
unlockedEffects: string[];
|
||||||
|
setSpell: (spellId: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpellsTab({ store }: SpellsTabProps) {
|
||||||
|
// Get spells from equipment
|
||||||
|
const equipmentSpellIds: string[] = [];
|
||||||
|
const spellSources: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
for (const instanceId of Object.values(store.equippedInstances)) {
|
||||||
|
if (!instanceId) continue;
|
||||||
|
const instance = store.equipmentInstances[instanceId];
|
||||||
|
if (!instance) continue;
|
||||||
|
|
||||||
|
for (const ench of instance.enchantments) {
|
||||||
|
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
|
||||||
|
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
|
||||||
|
const spellId = effectDef.effect.spellId;
|
||||||
|
if (!equipmentSpellIds.includes(spellId)) {
|
||||||
|
equipmentSpellIds.push(spellId);
|
||||||
|
}
|
||||||
|
if (!spellSources[spellId]) {
|
||||||
|
spellSources[spellId] = [];
|
||||||
|
}
|
||||||
|
spellSources[spellId].push(instance.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCastSpell = (spellId: string): boolean => {
|
||||||
|
const spell = SPELLS_DEF[spellId];
|
||||||
|
if (!spell) return false;
|
||||||
|
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Equipment-Granted Spells */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-3 text-cyan-400">✨ Known Spells</h3>
|
||||||
|
<p className="text-sm text-gray-400 mb-4">
|
||||||
|
Spells are obtained by enchanting equipment with spell effects.
|
||||||
|
Visit the Crafting tab to design and apply enchantments.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{equipmentSpellIds.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{equipmentSpellIds.map(id => {
|
||||||
|
const def = SPELLS_DEF[id];
|
||||||
|
if (!def) return null;
|
||||||
|
|
||||||
|
const isActive = store.activeSpell === id;
|
||||||
|
const canCast = canCastSpell(id);
|
||||||
|
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||||
|
const sources = spellSources[id] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={id}
|
||||||
|
className={`bg-gray-900/80 border-cyan-600/50 ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||||
|
{def.name}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge className="bg-cyan-900/50 text-cyan-300 text-xs">Equipment</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
|
||||||
|
<span>⚔️ {def.dmg} dmg</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||||
|
Cost: {formatSpellCost(def.cost)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-cyan-400/70">From: {sources.join(', ')}</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{isActive ? (
|
||||||
|
<Badge className="bg-amber-900/50 text-amber-300">Active</Badge>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => store.setSpell(id)}>
|
||||||
|
Set Active
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-8 bg-gray-800/30 rounded border border-gray-700">
|
||||||
|
<div className="text-gray-500 mb-2">No spells known yet</div>
|
||||||
|
<div className="text-sm text-gray-600">Enchant a staff with a spell effect to gain spells</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pact Spells (from guardian defeats) */}
|
||||||
|
{store.signedPacts.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-3 text-amber-400">🏆 Pact Spells</h3>
|
||||||
|
<p className="text-sm text-gray-400 mb-3">Spells earned through guardian pacts appear here.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spell Reference - show all available spells for enchanting */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-3 text-purple-400">📚 Spell Reference</h3>
|
||||||
|
<p className="text-sm text-gray-400 mb-4">
|
||||||
|
These spells can be applied to equipment through the enchanting system.
|
||||||
|
Research enchantment effects in the Skills tab to unlock them for designing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Object.entries(SPELLS_DEF).map(([id, def]) => {
|
||||||
|
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||||
|
const isUnlocked = store.unlockedEffects?.includes(`spell_${id}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={id}
|
||||||
|
className={`bg-gray-900/80 border-gray-700 ${isUnlocked ? 'border-purple-500/50' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||||
|
{def.name}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{def.tier > 0 && <Badge variant="outline" className="text-xs">T{def.tier}</Badge>}
|
||||||
|
{isUnlocked && <Badge className="bg-purple-900/50 text-purple-300 text-xs">Unlocked</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
|
||||||
|
<span>⚔️ {def.dmg} dmg</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||||
|
Cost: {formatSpellCost(def.cost)}
|
||||||
|
</div>
|
||||||
|
{def.desc && (
|
||||||
|
<div className="text-xs text-gray-500 italic">{def.desc}</div>
|
||||||
|
)}
|
||||||
|
{!isUnlocked && (
|
||||||
|
<div className="text-xs text-amber-400/70">Research to unlock for enchanting</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
345
src/components/game/tabs/SpireTab.tsx
Executable file
345
src/components/game/tabs/SpireTab.tsx
Executable file
@@ -0,0 +1,345 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X, Mountain } from 'lucide-react';
|
||||||
|
import type { GameStore } from '@/lib/game/types';
|
||||||
|
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { GOLEMS_DEF, getGolemDamage, getGolemAttackSpeed } from '@/lib/game/data/golems';
|
||||||
|
import { fmt, fmtDec, getFloorElement, canAffordSpellCost } from '@/lib/game/store';
|
||||||
|
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
|
||||||
|
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
|
||||||
|
import { CraftingProgress, StudyProgress } from '@/components/game';
|
||||||
|
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||||
|
|
||||||
|
interface SpireTabProps {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpireTab({ store }: SpireTabProps) {
|
||||||
|
const floorElem = getFloorElement(store.currentFloor);
|
||||||
|
const floorElemDef = ELEMENTS[floorElem];
|
||||||
|
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
|
||||||
|
const currentGuardian = GUARDIANS[store.currentFloor];
|
||||||
|
const climbDirection = store.climbDirection || 'up';
|
||||||
|
const clearedFloors = store.clearedFloors || {};
|
||||||
|
|
||||||
|
// Check if current floor is cleared (for respawn indicator)
|
||||||
|
const isFloorCleared = clearedFloors[store.currentFloor];
|
||||||
|
|
||||||
|
// Get active equipment spells
|
||||||
|
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
|
||||||
|
|
||||||
|
// Get upgrade effects and DPS
|
||||||
|
const upgradeEffects = getUnifiedEffects(store);
|
||||||
|
const totalDPS = getTotalDPS(store, upgradeEffects, floorElem);
|
||||||
|
const studySpeedMult = 1; // Base study speed
|
||||||
|
|
||||||
|
const canCastSpell = (spellId: string): boolean => {
|
||||||
|
const spell = SPELLS_DEF[spellId];
|
||||||
|
if (!spell) return false;
|
||||||
|
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Current Floor Card */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
|
||||||
|
{store.currentFloor}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-sm">/ 100</span>
|
||||||
|
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
|
||||||
|
{floorElemDef?.sym} {floorElemDef?.name}
|
||||||
|
</span>
|
||||||
|
{isGuardianFloor && (
|
||||||
|
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isGuardianFloor && currentGuardian && (
|
||||||
|
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
|
||||||
|
⚔️ {currentGuardian.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HP Bar */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
|
||||||
|
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
|
||||||
|
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 game-mono">
|
||||||
|
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
|
||||||
|
<span>DPS: {store.currentAction === 'climb' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
|
{/* Floor Navigation - Direction indicator only */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400">Direction</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Badge variant={climbDirection === 'up' ? 'default' : 'outline'}
|
||||||
|
className={climbDirection === 'up' ? 'bg-green-600' : ''}>
|
||||||
|
<ChevronUp className="w-3 h-3 mr-1" />
|
||||||
|
Up
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={climbDirection === 'down' ? 'default' : 'outline'}
|
||||||
|
className={climbDirection === 'down' ? 'bg-blue-600' : ''}>
|
||||||
|
<ChevronDown className="w-3 h-3 mr-1" />
|
||||||
|
Down
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFloorCleared && (
|
||||||
|
<div className="text-xs text-amber-400 text-center flex items-center justify-center gap-1">
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Floor cleared! Advancing...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong> •
|
||||||
|
Pacts: <strong className="text-amber-400">{store.signedPacts.length}</strong>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Spells Card - Shows all spells from equipped weapons */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||||
|
Active Spells ({activeEquipmentSpells.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{activeEquipmentSpells.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
|
||||||
|
const spellDef = SPELLS_DEF[spellId];
|
||||||
|
if (!spellDef) return null;
|
||||||
|
|
||||||
|
const spellState = store.equipmentSpellStates?.find(
|
||||||
|
s => s.spellId === spellId && s.sourceEquipment === equipmentId
|
||||||
|
);
|
||||||
|
const progress = spellState?.castProgress || 0;
|
||||||
|
const canCast = canAffordSpellCost(spellDef.cost, store.rawMana, store.elements);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${spellId}-${equipmentId}`} className="p-2 rounded border border-gray-700 bg-gray-800/30">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="text-sm font-semibold game-panel-title" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
|
||||||
|
{spellDef.name}
|
||||||
|
{spellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200 text-xs">Basic</Badge>}
|
||||||
|
{spellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100 text-xs">Legendary</Badge>}
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{canCast ? '✓' : '✗'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 game-mono mb-1">
|
||||||
|
⚔️ {fmt(totalDPS)} DPS •
|
||||||
|
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
|
||||||
|
{' '}{formatSpellCost(spellDef.cost)}
|
||||||
|
</span>
|
||||||
|
{' '}• ⚡ {(spellDef.castSpeed || 1).toFixed(1)}/hr
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cast progress bar when climbing */}
|
||||||
|
{store.currentAction === 'climb' && (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>Cast</span>
|
||||||
|
<span>{(progress * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={Math.min(100, progress * 100)} className="h-1.5 bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{spellDef.effects && spellDef.effects.length > 0 && (
|
||||||
|
<div className="flex gap-1 flex-wrap mt-1">
|
||||||
|
{spellDef.effects.map((eff, i) => (
|
||||||
|
<Badge key={i} variant="outline" className="text-xs py-0">
|
||||||
|
{eff.type === 'burn' && `🔥 Burn`}
|
||||||
|
{eff.type === 'freeze' && `❄️ Freeze`}
|
||||||
|
{eff.type === 'stun' && `⚡ Stun`}
|
||||||
|
{eff.type === 'armor_pierce' && `🗡️ Pierce`}
|
||||||
|
{eff.type === 'buff' && `⬆ Buff`}
|
||||||
|
{eff.type === 'chain' && `⛓️ Chain`}
|
||||||
|
{eff.type === 'aoe' && `💥 AOE`}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects in the Crafting tab.</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Summoned Golems Card */}
|
||||||
|
{store.golemancy.summonedGolems.length > 0 && (
|
||||||
|
<Card className="bg-gray-900/80 border-amber-600/50">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Mountain className="w-4 h-4" />
|
||||||
|
Active Golems ({store.golemancy.summonedGolems.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{store.golemancy.summonedGolems.map((summoned) => {
|
||||||
|
const golemDef = GOLEMS_DEF[summoned.golemId];
|
||||||
|
if (!golemDef) return null;
|
||||||
|
|
||||||
|
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
|
||||||
|
const damage = getGolemDamage(summoned.golemId, store.skills);
|
||||||
|
const attackSpeed = getGolemAttackSpeed(summoned.golemId, store.skills);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={summoned.golemId} className="p-2 rounded border border-gray-700 bg-gray-800/30">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mountain className="w-4 h-4" style={{ color: elemColor }} />
|
||||||
|
<span className="text-sm font-semibold game-panel-title" style={{ color: elemColor }}>
|
||||||
|
{golemDef.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{golemDef.isAoe && (
|
||||||
|
<Badge variant="outline" className="text-xs">AOE {golemDef.aoeTargets}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 game-mono">
|
||||||
|
⚔️ {damage} DMG • ⚡ {attackSpeed.toFixed(1)}/hr •
|
||||||
|
🛡️ {Math.floor(golemDef.armorPierce * 100)}% Pierce
|
||||||
|
</div>
|
||||||
|
{/* Attack progress bar when climbing */}
|
||||||
|
{store.currentAction === 'climb' && summoned.attackProgress > 0 && (
|
||||||
|
<div className="space-y-0.5 mt-1">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>Attack</span>
|
||||||
|
<span>{Math.min(100, (summoned.attackProgress * 100)).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={Math.min(100, summoned.attackProgress * 100)} className="h-1.5 bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Study (if any) */}
|
||||||
|
{store.currentStudyTarget && (
|
||||||
|
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
<StudyProgress
|
||||||
|
currentStudyTarget={store.currentStudyTarget}
|
||||||
|
skills={store.skills}
|
||||||
|
studySpeedMult={studySpeedMult}
|
||||||
|
cancelStudy={store.cancelStudy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Parallel Study Progress */}
|
||||||
|
{store.parallelStudyTarget && (
|
||||||
|
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-sm font-semibold text-cyan-300">
|
||||||
|
Parallel: {SKILLS_DEF[store.parallelStudyTarget.id]?.name}
|
||||||
|
{store.parallelStudyTarget.type === 'skill' && ` Lv.${(store.skills[store.parallelStudyTarget.id] || 0) + 1}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={() => store.cancelParallelStudy?.()}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
|
||||||
|
<span>50% speed (Parallel Study)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Crafting Progress (if any) */}
|
||||||
|
{(store.designProgress || store.preparationProgress || store.applicationProgress) && (
|
||||||
|
<Card className="bg-gray-900/80 border-cyan-600/50 lg:col-span-2">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<CraftingProgress
|
||||||
|
designProgress={store.designProgress}
|
||||||
|
preparationProgress={store.preparationProgress}
|
||||||
|
applicationProgress={store.applicationProgress}
|
||||||
|
equipmentInstances={store.equipmentInstances}
|
||||||
|
enchantmentDesigns={store.enchantmentDesigns}
|
||||||
|
cancelDesign={store.cancelDesign!}
|
||||||
|
cancelPreparation={store.cancelPreparation!}
|
||||||
|
pauseApplication={store.pauseApplication!}
|
||||||
|
resumeApplication={store.resumeApplication!}
|
||||||
|
cancelApplication={store.cancelApplication!}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity Log */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-32">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{store.log.slice(0, 20).map((entry, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
|
||||||
|
>
|
||||||
|
{entry}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
545
src/components/game/tabs/StatsTab.tsx
Executable file
545
src/components/game/tabs/StatsTab.tsx
Executable file
@@ -0,0 +1,545 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ELEMENTS, GUARDIANS, SKILLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||||
|
import { hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
|
||||||
|
import { fmt, fmtDec, calcDamage } from '@/lib/game/store';
|
||||||
|
import type { SkillUpgradeChoice, GameStore, UnifiedEffects } from '@/lib/game/types';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Droplet, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Star } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface StatsTabProps {
|
||||||
|
store: GameStore;
|
||||||
|
upgradeEffects: UnifiedEffects;
|
||||||
|
maxMana: number;
|
||||||
|
baseRegen: number;
|
||||||
|
clickMana: number;
|
||||||
|
meditationMultiplier: number;
|
||||||
|
effectiveRegen: number;
|
||||||
|
incursionStrength: number;
|
||||||
|
manaCascadeBonus: number;
|
||||||
|
studySpeedMult: number;
|
||||||
|
studyCostMult: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsTab({
|
||||||
|
store,
|
||||||
|
upgradeEffects,
|
||||||
|
maxMana,
|
||||||
|
baseRegen,
|
||||||
|
clickMana,
|
||||||
|
meditationMultiplier,
|
||||||
|
effectiveRegen,
|
||||||
|
incursionStrength,
|
||||||
|
manaCascadeBonus,
|
||||||
|
studySpeedMult,
|
||||||
|
studyCostMult,
|
||||||
|
}: StatsTabProps) {
|
||||||
|
// Compute element max
|
||||||
|
const elemMax = (() => {
|
||||||
|
const ea = store.skillTiers?.elemAttune || 1;
|
||||||
|
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
|
||||||
|
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
|
||||||
|
const tierMult = getTierMultiplier(tieredSkillId);
|
||||||
|
return 10 + level * 50 * tierMult + (store.prestigeUpgrades.elementalAttune || 0) * 25;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Get all selected skill upgrades
|
||||||
|
const getAllSelectedUpgrades = () => {
|
||||||
|
const upgrades: { skillId: string; upgrade: SkillUpgradeChoice }[] = [];
|
||||||
|
for (const [skillId, selectedIds] of Object.entries(store.skillUpgrades)) {
|
||||||
|
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||||
|
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||||
|
if (!path) continue;
|
||||||
|
for (const tier of path.tiers) {
|
||||||
|
if (tier.skillId === skillId) {
|
||||||
|
for (const upgradeId of selectedIds) {
|
||||||
|
const upgrade = tier.upgrades.find(u => u.id === upgradeId);
|
||||||
|
if (upgrade) {
|
||||||
|
upgrades.push({ skillId, upgrade });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return upgrades;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedUpgrades = getAllSelectedUpgrades();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Mana Stats */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-blue-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Droplet className="w-4 h-4" />
|
||||||
|
Mana Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Base Max Mana:</span>
|
||||||
|
<span className="text-gray-200">100</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Well Bonus:</span>
|
||||||
|
<span className="text-blue-300">
|
||||||
|
{(() => {
|
||||||
|
const mw = store.skillTiers?.manaWell || 1;
|
||||||
|
const tieredSkillId = mw > 1 ? `manaWell_t${mw}` : 'manaWell';
|
||||||
|
const level = store.skills[tieredSkillId] || store.skills.manaWell || 0;
|
||||||
|
const tierMult = getTierMultiplier(tieredSkillId);
|
||||||
|
return `+${fmt(level * 100 * tierMult)} (${level} lvl × 100 × ${tierMult}x tier)`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Prestige Mana Well:</span>
|
||||||
|
<span className="text-blue-300">+{fmt((store.prestigeUpgrades.manaWell || 0) * 500)}</span>
|
||||||
|
</div>
|
||||||
|
{upgradeEffects.maxManaBonus > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-amber-400">Upgrade Mana Bonus:</span>
|
||||||
|
<span className="text-amber-300">+{fmt(upgradeEffects.maxManaBonus)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgradeEffects.maxManaMultiplier > 1 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-amber-400">Upgrade Mana Multiplier:</span>
|
||||||
|
<span className="text-amber-300">×{fmtDec(upgradeEffects.maxManaMultiplier, 2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||||
|
<span className="text-gray-300">Total Max Mana:</span>
|
||||||
|
<span className="text-blue-400">{fmt(maxMana)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Base Regen:</span>
|
||||||
|
<span className="text-gray-200">2/hr</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Flow Bonus:</span>
|
||||||
|
<span className="text-blue-300">
|
||||||
|
{(() => {
|
||||||
|
const mf = store.skillTiers?.manaFlow || 1;
|
||||||
|
const tieredSkillId = mf > 1 ? `manaFlow_t${mf}` : 'manaFlow';
|
||||||
|
const level = store.skills[tieredSkillId] || store.skills.manaFlow || 0;
|
||||||
|
const tierMult = getTierMultiplier(tieredSkillId);
|
||||||
|
return `+${fmtDec(level * 1 * tierMult)}/hr (${level} lvl × 1 × ${tierMult}x tier)`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Spring Bonus:</span>
|
||||||
|
<span className="text-blue-300">+{(store.skills.manaSpring || 0) * 2}/hr</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Prestige Mana Flow:</span>
|
||||||
|
<span className="text-blue-300">+{fmtDec((store.prestigeUpgrades.manaFlow || 0) * 0.5)}/hr</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Temporal Echo:</span>
|
||||||
|
<span className="text-blue-300">×{fmtDec(1 + (store.prestigeUpgrades.temporalEcho || 0) * 0.1, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||||
|
<span className="text-gray-300">Base Regen:</span>
|
||||||
|
<span className="text-blue-400">{fmtDec(baseRegen, 2)}/hr</span>
|
||||||
|
</div>
|
||||||
|
{upgradeEffects.regenBonus > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-amber-400">Upgrade Regen Bonus:</span>
|
||||||
|
<span className="text-amber-300">+{fmtDec(upgradeEffects.regenBonus, 2)}/hr</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgradeEffects.permanentRegenBonus > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-amber-400">Permanent Regen Bonus:</span>
|
||||||
|
<span className="text-amber-300">+{fmtDec(upgradeEffects.permanentRegenBonus, 2)}/hr</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgradeEffects.regenMultiplier > 1 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-amber-400">Upgrade Regen Multiplier:</span>
|
||||||
|
<span className="text-amber-300">×{fmtDec(upgradeEffects.regenMultiplier, 2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-gray-700 my-3" />
|
||||||
|
{upgradeEffects.activeUpgrades.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-xs text-amber-400 game-panel-title">Active Skill Upgrades</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3">
|
||||||
|
{upgradeEffects.activeUpgrades.map((upgrade, idx) => (
|
||||||
|
<div key={idx} className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||||
|
<span className="text-gray-300">{upgrade.name}</span>
|
||||||
|
<span className="text-gray-400">{upgrade.desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-gray-700 my-3" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Click Mana Value:</span>
|
||||||
|
<span className="text-purple-300">+{clickMana}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Tap Bonus:</span>
|
||||||
|
<span className="text-purple-300">+{store.skills.manaTap || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Surge Bonus:</span>
|
||||||
|
<span className="text-purple-300">+{(store.skills.manaSurge || 0) * 3}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Mana Overflow:</span>
|
||||||
|
<span className="text-purple-300">×{fmtDec(1 + (store.skills.manaOverflow || 0) * 0.25, 2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Meditation Multiplier:</span>
|
||||||
|
<span className={`font-semibold ${meditationMultiplier > 1.5 ? 'text-purple-400' : 'text-gray-300'}`}>
|
||||||
|
{fmtDec(meditationMultiplier, 2)}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Effective Regen:</span>
|
||||||
|
<span className="text-green-400 font-semibold">{fmtDec(effectiveRegen, 2)}/hr</span>
|
||||||
|
</div>
|
||||||
|
{incursionStrength > 0 && !hasSpecial(upgradeEffects, SPECIAL_EFFECTS.STEADY_STREAM) && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-red-400">Incursion Penalty:</span>
|
||||||
|
<span className="text-red-400">-{Math.round(incursionStrength * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.STEADY_STREAM) && incursionStrength > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-green-400">Steady Stream:</span>
|
||||||
|
<span className="text-green-400">Immune to incursion</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{manaCascadeBonus > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-cyan-400">Mana Cascade Bonus:</span>
|
||||||
|
<span className="text-cyan-400">+{fmtDec(manaCascadeBonus, 2)}/hr</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_TORRENT) && store.rawMana > maxMana * 0.75 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-cyan-400">Mana Torrent:</span>
|
||||||
|
<span className="text-cyan-400">+50% regen (high mana)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.DESPERATE_WELLS) && store.rawMana < maxMana * 0.25 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-cyan-400">Desperate Wells:</span>
|
||||||
|
<span className="text-cyan-400">+50% regen (low mana)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Combat Stats */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-red-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Swords className="w-4 h-4" />
|
||||||
|
Combat Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Combat Training Bonus:</span>
|
||||||
|
<span className="text-red-300">+{(store.skills.combatTrain || 0) * 5}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Arcane Fury Multiplier:</span>
|
||||||
|
<span className="text-red-300">×{fmtDec(1 + (store.skills.arcaneFury || 0) * 0.1, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Elemental Mastery:</span>
|
||||||
|
<span className="text-red-300">×{fmtDec(1 + (store.skills.elementalMastery || 0) * 0.15, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Guardian Bane:</span>
|
||||||
|
<span className="text-red-300">×{fmtDec(1 + (store.skills.guardianBane || 0) * 0.2, 2)} (vs guardians)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Critical Hit Chance:</span>
|
||||||
|
<span className="text-amber-300">{((store.skills.precision || 0) * 5)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Critical Multiplier:</span>
|
||||||
|
<span className="text-amber-300">1.5x</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Spell Echo Chance:</span>
|
||||||
|
<span className="text-amber-300">{((store.skills.spellEcho || 0) * 10)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Pact Multiplier:</span>
|
||||||
|
<span className="text-amber-300">×{fmtDec(store.signedPacts.reduce((m, f) => m * (GUARDIANS[f]?.pact || 1), 1), 2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Study Stats */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
Study Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Study Speed:</span>
|
||||||
|
<span className="text-purple-300">×{fmtDec(studySpeedMult, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Quick Learner Bonus:</span>
|
||||||
|
<span className="text-purple-300">+{((store.skills.quickLearner || 0) * 10)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Study Cost:</span>
|
||||||
|
<span className="text-purple-300">{Math.round(studyCostMult * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Focused Mind Bonus:</span>
|
||||||
|
<span className="text-purple-300">-{((store.skills.focusedMind || 0) * 5)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Progress Retention:</span>
|
||||||
|
<span className="text-purple-300">{Math.round((1 + (store.skills.knowledgeRetention || 0) * 0.2) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Element Stats */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-green-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<FlaskConical className="w-4 h-4" />
|
||||||
|
Element Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Element Capacity:</span>
|
||||||
|
<span className="text-green-300">{elemMax}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Elem. Attunement Bonus:</span>
|
||||||
|
<span className="text-green-300">
|
||||||
|
{(() => {
|
||||||
|
const ea = store.skillTiers?.elemAttune || 1;
|
||||||
|
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
|
||||||
|
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
|
||||||
|
const tierMult = getTierMultiplier(tieredSkillId);
|
||||||
|
return `+${level * 50 * tierMult}`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Prestige Attunement:</span>
|
||||||
|
<span className="text-green-300">+{(store.prestigeUpgrades.elementalAttune || 0) * 25}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Unlocked Elements:</span>
|
||||||
|
<span className="text-green-300">{Object.values(store.elements).filter(e => e.unlocked).length} / {Object.keys(ELEMENTS).length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Elem. Crafting Bonus:</span>
|
||||||
|
<span className="text-green-300">×{fmtDec(1 + (store.skills.elemCrafting || 0) * 0.25, 2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-gray-700 my-3" />
|
||||||
|
<div className="text-xs text-gray-400 mb-2">Elemental Mana Pools:</div>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||||
|
{Object.entries(store.elements)
|
||||||
|
.filter(([, state]) => state.unlocked)
|
||||||
|
.map(([id, state]) => {
|
||||||
|
const def = ELEMENTS[id];
|
||||||
|
return (
|
||||||
|
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 text-center">
|
||||||
|
<div className="text-lg">{def?.sym}</div>
|
||||||
|
<div className="text-xs text-gray-400">{state.current}/{state.max}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Upgrades */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
Active Skill Upgrades ({selectedUpgrades.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{selectedUpgrades.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-sm">No skill upgrades selected yet. Level skills to 5 or 10 to choose upgrades.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
{selectedUpgrades.map(({ skillId, upgrade }) => (
|
||||||
|
<div key={upgrade.id} className="p-2 rounded border border-amber-600/30 bg-amber-900/10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-amber-300 text-sm font-semibold">{upgrade.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs text-gray-400">
|
||||||
|
{SKILLS_DEF[skillId]?.name || skillId}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||||
|
{upgrade.effect.type === 'multiplier' && (
|
||||||
|
<div className="text-xs text-green-400 mt-1">
|
||||||
|
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgrade.effect.type === 'bonus' && (
|
||||||
|
<div className="text-xs text-blue-400 mt-1">
|
||||||
|
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upgrade.effect.type === 'special' && (
|
||||||
|
<div className="text-xs text-cyan-400 mt-1">
|
||||||
|
⚡ {upgrade.effect.specialDesc || 'Special effect active'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pact Bonuses */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<Trophy className="w-4 h-4" />
|
||||||
|
Signed Pacts ({store.signedPacts.length}/10)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{store.signedPacts.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-sm">No pacts signed yet. Defeat guardians to earn pacts.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{store.signedPacts.map((floor) => {
|
||||||
|
const guardian = GUARDIANS[floor];
|
||||||
|
if (!guardian) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={floor}
|
||||||
|
className="flex items-center justify-between p-2 rounded border"
|
||||||
|
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}15` }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||||
|
{guardian.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Floor {floor}</div>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-amber-900/50 text-amber-300">
|
||||||
|
{guardian.pact}x multiplier
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2 mt-2">
|
||||||
|
<span className="text-gray-300">Combined Pact Multiplier:</span>
|
||||||
|
<span className="text-amber-400">×{fmtDec(store.signedPacts.reduce((m, f) => m * (GUARDIANS[f]?.pact || 1), 1), 2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Loop Stats */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Loop Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||||
|
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Current Insight</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Insight</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-400 game-mono">{store.maxFloorReached}</div>
|
||||||
|
<div className="text-xs text-gray-400">Max Floor</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-gray-700 my-3" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.spells).filter(s => s.learned).length}</div>
|
||||||
|
<div className="text-xs text-gray-400">Spells Learned</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.skills).reduce((a, b) => a + b, 0)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Skill Levels</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-gray-300 game-mono">{fmt(store.totalManaGathered)}</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Mana Gathered</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-gray-300 game-mono">{store.memorySlots}</div>
|
||||||
|
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/game/tabs/StudyProgress.tsx
Executable file
73
src/components/game/tabs/StudyProgress.tsx
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { formatStudyTime } from '@/lib/game/formatting';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import type { StudyTarget } from '@/lib/game/types';
|
||||||
|
|
||||||
|
export interface StudyProgressProps {
|
||||||
|
currentStudyTarget: StudyTarget;
|
||||||
|
skills: Record<string, number>;
|
||||||
|
studySpeedMult: number;
|
||||||
|
cancelStudy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudyProgress({
|
||||||
|
currentStudyTarget,
|
||||||
|
skills,
|
||||||
|
studySpeedMult,
|
||||||
|
cancelStudy
|
||||||
|
}: StudyProgressProps) {
|
||||||
|
const { id, progress, required } = currentStudyTarget;
|
||||||
|
|
||||||
|
// Get skill name
|
||||||
|
const baseId = id.includes('_t') ? id.split('_t')[0] : id;
|
||||||
|
const skillDef = SKILLS_DEF[baseId];
|
||||||
|
const skillName = skillDef?.name || id;
|
||||||
|
|
||||||
|
// Get current level
|
||||||
|
const currentLevel = skills[id] || skills[baseId] || 0;
|
||||||
|
|
||||||
|
// Calculate progress percentage
|
||||||
|
const progressPercent = Math.min((progress / required) * 100, 100);
|
||||||
|
|
||||||
|
// Estimated completion
|
||||||
|
const remainingHours = required - progress;
|
||||||
|
const effectiveSpeed = studySpeedMult;
|
||||||
|
const realTimeRemaining = remainingHours / effectiveSpeed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-purple-300 font-semibold">{skillName}</span>
|
||||||
|
<span className="text-gray-400 ml-2">
|
||||||
|
Level {currentLevel} → {currentLevel + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={cancelStudy}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>{formatStudyTime(progress)} / {formatStudyTime(required)}</span>
|
||||||
|
<span>{progressPercent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPercent} className="h-2" />
|
||||||
|
{studySpeedMult > 1 && (
|
||||||
|
<div className="text-xs text-green-400">
|
||||||
|
Speed: {(studySpeedMult * 100).toFixed(0)}% • ETA: {formatStudyTime(realTimeRemaining)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/game/tabs/UpgradeDialog.tsx
Executable file
116
src/components/game/tabs/UpgradeDialog.tsx
Executable file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { getUpgradesForSkillAtMilestone, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||||
|
|
||||||
|
export interface UpgradeDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
skillId: string | null;
|
||||||
|
milestone: 5 | 10;
|
||||||
|
pendingSelections: string[];
|
||||||
|
available: SkillUpgradeChoice[];
|
||||||
|
alreadySelected: string[];
|
||||||
|
onToggle: (upgradeId: string) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpgradeDialog({
|
||||||
|
open,
|
||||||
|
skillId,
|
||||||
|
milestone,
|
||||||
|
pendingSelections,
|
||||||
|
available,
|
||||||
|
alreadySelected,
|
||||||
|
onToggle,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
onOpenChange,
|
||||||
|
}: UpgradeDialogProps) {
|
||||||
|
if (!skillId) return null;
|
||||||
|
|
||||||
|
// Get skill name
|
||||||
|
const baseId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||||
|
const skillDef = SKILLS_DEF[baseId];
|
||||||
|
const skillName = skillDef?.name || skillId;
|
||||||
|
|
||||||
|
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
|
||||||
|
const canConfirm = currentSelections.length === 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md bg-gray-900 border-purple-600/50">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-amber-400">
|
||||||
|
Level {milestone} Milestone: {skillName}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
Choose 2 upgrades for this skill. These choices are permanent.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2 py-4">
|
||||||
|
{available.map((upgrade) => {
|
||||||
|
const isSelected = currentSelections.includes(upgrade.id);
|
||||||
|
const canSelect = isSelected || currentSelections.length < 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={upgrade.id}
|
||||||
|
onClick={() => canSelect && onToggle(upgrade.id)}
|
||||||
|
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-amber-500 bg-amber-900/30'
|
||||||
|
: canSelect
|
||||||
|
? 'border-gray-700 hover:border-gray-500 bg-gray-800/30'
|
||||||
|
: 'border-gray-800 bg-gray-900/30 opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`font-semibold text-sm ${isSelected ? 'text-amber-300' : 'text-gray-200'}`}>
|
||||||
|
{upgrade.name}
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<Badge className="bg-amber-600/50 text-amber-200 text-xs">Selected</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{upgrade.desc}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{available.length === 0 && (
|
||||||
|
<div className="text-center text-gray-500 py-4">
|
||||||
|
No upgrades available at this milestone.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
|
disabled={!canConfirm}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Confirm ({currentSelections.length}/2)
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/game/tabs/index.ts
Executable file
15
src/components/game/tabs/index.ts
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
// ─── Tab Components Index ──────────────────────────────────────────────────────
|
||||||
|
// Re-exports all tab components for cleaner imports
|
||||||
|
|
||||||
|
export { CraftingTab } from './CraftingTab';
|
||||||
|
export { SpireTab } from './SpireTab';
|
||||||
|
export { SpellsTab } from './SpellsTab';
|
||||||
|
export { LabTab } from './LabTab';
|
||||||
|
export { SkillsTab } from './SkillsTab';
|
||||||
|
export { StatsTab } from './StatsTab';
|
||||||
|
export { EquipmentTab } from './EquipmentTab';
|
||||||
|
export { AttunementsTab } from './AttunementsTab';
|
||||||
|
export { DebugTab } from './DebugTab';
|
||||||
|
export { LootTab } from './LootTab';
|
||||||
|
export { AchievementsTab } from './AchievementsTab';
|
||||||
|
export { GolemancyTab } from './GolemancyTab';
|
||||||
47
src/components/game/types.ts
Executable file
47
src/components/game/types.ts
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { SpellCost } from '@/lib/game/types';
|
||||||
|
import { Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull } from 'lucide-react';
|
||||||
|
|
||||||
|
// Format spell cost for display
|
||||||
|
export function formatSpellCost(cost: SpellCost): string {
|
||||||
|
if (cost.type === 'raw') {
|
||||||
|
return `${cost.amount} raw`;
|
||||||
|
}
|
||||||
|
const elemDef = ELEMENTS[cost.element || ''];
|
||||||
|
return `${cost.amount} ${elemDef?.sym || '?'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format time (hour to HH:MM)
|
||||||
|
export function formatTime(hour: number): string {
|
||||||
|
const h = Math.floor(hour);
|
||||||
|
const m = Math.floor((hour % 1) * 60);
|
||||||
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format study time
|
||||||
|
export function formatStudyTime(hours: number): string {
|
||||||
|
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||||
|
return `${hours.toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element icons mapping
|
||||||
|
export const ELEMENT_ICONS: Record<string, typeof Flame> = {
|
||||||
|
fire: Flame,
|
||||||
|
water: Droplet,
|
||||||
|
wind: Wind,
|
||||||
|
earth: Mountain,
|
||||||
|
light: Sun,
|
||||||
|
shadow: Moon,
|
||||||
|
nature: Leaf,
|
||||||
|
death: Skull,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Import ELEMENTS at the bottom to avoid circular deps
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
|
|
||||||
|
// Get cost color
|
||||||
|
export function getSpellCostColor(cost: SpellCost): string {
|
||||||
|
if (cost.type === 'raw') {
|
||||||
|
return '#60A5FA'; // Blue for raw mana
|
||||||
|
}
|
||||||
|
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
|
||||||
|
}
|
||||||
66
src/components/ui/accordion.tsx
Executable file
66
src/components/ui/accordion.tsx
Executable file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
157
src/components/ui/alert-dialog.tsx
Executable file
157
src/components/ui/alert-dialog.tsx
Executable file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
66
src/components/ui/alert.tsx
Executable file
66
src/components/ui/alert.tsx
Executable file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
11
src/components/ui/aspect-ratio.tsx
Executable file
11
src/components/ui/aspect-ratio.tsx
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||||
|
|
||||||
|
function AspectRatio({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||||
|
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
53
src/components/ui/avatar.tsx
Executable file
53
src/components/ui/avatar.tsx
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
46
src/components/ui/badge.tsx
Executable file
46
src/components/ui/badge.tsx
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
109
src/components/ui/breadcrumb.tsx
Executable file
109
src/components/ui/breadcrumb.tsx
Executable file
@@ -0,0 +1,109 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
59
src/components/ui/button.tsx
Executable file
59
src/components/ui/button.tsx
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
213
src/components/ui/calendar.tsx
Executable file
213
src/components/ui/calendar.tsx
Executable file
@@ -0,0 +1,213 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"flex gap-4 flex-col md:flex-row relative",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute bg-popover inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"select-none w-(--cell-size)",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] select-none text-muted-foreground",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"rounded-l-md bg-accent",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
92
src/components/ui/card.tsx
Executable file
92
src/components/ui/card.tsx
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
241
src/components/ui/carousel.tsx
Executable file
241
src/components/ui/carousel.tsx
Executable file
@@ -0,0 +1,241 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
type UseEmblaCarouselType,
|
||||||
|
} from "embla-carousel-react"
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1]
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||||
|
type CarouselOptions = UseCarouselParameters[0]
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1]
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions
|
||||||
|
plugins?: CarouselPlugin
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
setApi?: (api: CarouselApi) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||||
|
scrollPrev: () => void
|
||||||
|
scrollNext: () => void
|
||||||
|
canScrollPrev: boolean
|
||||||
|
canScrollNext: boolean
|
||||||
|
} & CarouselProps
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function Carousel({
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins
|
||||||
|
)
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) return
|
||||||
|
setCanScrollPrev(api.canScrollPrev())
|
||||||
|
setCanScrollNext(api.canScrollNext())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollPrev()
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollNext()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) return
|
||||||
|
setApi(api)
|
||||||
|
}, [api, setApi])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) return
|
||||||
|
onSelect(api)
|
||||||
|
api.on("reInit", onSelect)
|
||||||
|
api.on("select", onSelect)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off("select", onSelect)
|
||||||
|
}
|
||||||
|
}, [api, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
data-slot="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { carouselRef, orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className="overflow-hidden"
|
||||||
|
data-slot="carousel-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
data-slot="carousel-item"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselPrevious({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-previous"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -left-12 -translate-y-1/2"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselNext({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-next"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -right-12 -translate-y-1/2"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
}
|
||||||
353
src/components/ui/chart.tsx
Executable file
353
src/components/ui/chart.tsx
Executable file
@@ -0,0 +1,353 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RechartsPrimitive from "recharts"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"]
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: "line" | "dot" | "dashed"
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey: string = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
||||||
32
src/components/ui/checkbox.tsx
Executable file
32
src/components/ui/checkbox.tsx
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
33
src/components/ui/collapsible.tsx
Executable file
33
src/components/ui/collapsible.tsx
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user