init
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,6 +10,8 @@
|
|||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
|
||||||
|
/.idea
|
||||||
|
/.history
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
|||||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
7
.idea/discord.xml
generated
Normal file
7
.idea/discord.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DiscordProjectSettings">
|
||||||
|
<option name="show" value="ASK" />
|
||||||
|
<option name="description" value="" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
12
.idea/firefly-analytics.iml
generated
Normal file
12
.idea/firefly-analytics.iml
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/firefly-analytics.iml" filepath="$PROJECT_DIR$/.idea/firefly-analytics.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
11
i18n/request.ts
Normal file
11
i18n/request.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { getRequestConfig } from "next-intl/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
export default getRequestConfig( async () => {
|
||||||
|
const locale = (await cookies()).get("MYNEXTAPP_LOCALE")?.value || "en";
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default
|
||||||
|
}
|
||||||
|
})
|
||||||
75
messages/cn.json
Normal file
75
messages/cn.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"TabTitle": {
|
||||||
|
"title": "Firefly 分析",
|
||||||
|
"description": "Veritas的分析工具"
|
||||||
|
},
|
||||||
|
"DataAnalysisPage": {
|
||||||
|
"useSkill": "使用技能",
|
||||||
|
"totalDamage": "总伤害",
|
||||||
|
"damagePerAV": "每AV伤害",
|
||||||
|
"totalAV": "总AV",
|
||||||
|
"damagerPerCycle": "每个周期的伤害",
|
||||||
|
"skillType": "技能类型",
|
||||||
|
"skillName": "技能名称",
|
||||||
|
"actionValue": "行动值",
|
||||||
|
"character": "角色",
|
||||||
|
"id": "ID",
|
||||||
|
"path": "路径",
|
||||||
|
"rarity": "稀有度",
|
||||||
|
"damageByActionValue": "根据行动值的伤害",
|
||||||
|
"element": "元素",
|
||||||
|
"totalTurn": "总回合",
|
||||||
|
"technique": "技巧",
|
||||||
|
"talent": "天赋",
|
||||||
|
"basic": "基础攻击",
|
||||||
|
"skill": "技能",
|
||||||
|
"ultimate": "终极技能",
|
||||||
|
"servant": "从者",
|
||||||
|
"skillDamageBreakdown": "技能伤害细分",
|
||||||
|
"skillUsageDistribution": "技能使用分布",
|
||||||
|
"damageOverTime": "持续伤害",
|
||||||
|
"damage": "伤害",
|
||||||
|
"cumulativeDamage": "累计伤害",
|
||||||
|
"characterInformation": "角色信息",
|
||||||
|
"turnDetail": "回合详情",
|
||||||
|
"damageDetails": "伤害详情",
|
||||||
|
"cycleCount": "周期数",
|
||||||
|
"chartInfo": "图表信息",
|
||||||
|
"actionBar": "行动时间线",
|
||||||
|
"lineupInfo": "阵容信息",
|
||||||
|
"loadData": "加载战斗数据",
|
||||||
|
"exportData": "导出战斗数据",
|
||||||
|
"connectSetting": "连接设置",
|
||||||
|
"connected": "已连接",
|
||||||
|
"unconnected": "未连接",
|
||||||
|
"socketConnection": "套接字连接",
|
||||||
|
"connectionType": "连接类型",
|
||||||
|
"status": "状态",
|
||||||
|
"connect": "连接",
|
||||||
|
"checkGameConnect": "检查游戏连接",
|
||||||
|
"other": "其他",
|
||||||
|
"host": "主机",
|
||||||
|
"port": "端口",
|
||||||
|
"hostPlaceHolder": "输入主机地址",
|
||||||
|
"portPlaceHolder": "输入端口号",
|
||||||
|
"noDamageDetail": "没有可用的伤害详情",
|
||||||
|
"noCharactersInLineup": "队伍中没有角色",
|
||||||
|
"noTurns": "尚未有回合",
|
||||||
|
"style": "风格",
|
||||||
|
"warrior": "毁灭",
|
||||||
|
"knight": "存护",
|
||||||
|
"mage": "知识",
|
||||||
|
"priest": "丰饶",
|
||||||
|
"rouge": "巡猎",
|
||||||
|
"shaman": "协调",
|
||||||
|
"warlock": "虚无",
|
||||||
|
"memory": "记忆",
|
||||||
|
"fire": "火",
|
||||||
|
"ice": "冰",
|
||||||
|
"imaginary": "虚数",
|
||||||
|
"physical": "物理",
|
||||||
|
"quantum": "量子",
|
||||||
|
"thunder": "雷",
|
||||||
|
"wind": "风"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
messages/en.json
Normal file
75
messages/en.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"TabTitle": {
|
||||||
|
"title": "Firely Analytics",
|
||||||
|
"description": "Analytics tool for Veritas"
|
||||||
|
},
|
||||||
|
"DataAnalysisPage": {
|
||||||
|
"useSkill": "Use skill",
|
||||||
|
"totalDamage": "Total damage",
|
||||||
|
"damagePerAV": "Damage per AV",
|
||||||
|
"totalAV": "Total action value",
|
||||||
|
"damagerPerCycle": "Damage Per Cycle",
|
||||||
|
"skillType": "Skill Type",
|
||||||
|
"skillName": "Skill Name",
|
||||||
|
"actionValue": "Action Value",
|
||||||
|
"character": "Character",
|
||||||
|
"id": "Id",
|
||||||
|
"path": "Path",
|
||||||
|
"rarity": "Rarity",
|
||||||
|
"element": "Element",
|
||||||
|
"totalTurn": "Total Turn",
|
||||||
|
"technique": "Technique",
|
||||||
|
"talent": "Talent",
|
||||||
|
"basic": "Basic Attack",
|
||||||
|
"skill": "Skill",
|
||||||
|
"ultimate": "Ultimate",
|
||||||
|
"servant": "Servant",
|
||||||
|
"skillDamageBreakdown": "Skill Damage Breakdown",
|
||||||
|
"skillUsageDistribution": "Skill Usage Distribution",
|
||||||
|
"damageOverTime": "Damage Over Time",
|
||||||
|
"damage": "Damage",
|
||||||
|
"cumulativeDamage": "Cumulative Damage",
|
||||||
|
"damageByActionValue": "Damage by Action Value",
|
||||||
|
"characterInformation": "Character Information",
|
||||||
|
"turnDetail": "Turn Details",
|
||||||
|
"damageDetails": "Damage Details",
|
||||||
|
"cycleCount": "Cycle Count",
|
||||||
|
"chartInfo": "Chart info",
|
||||||
|
"actionBar": "Action time line",
|
||||||
|
"lineupInfo": "Lineup info",
|
||||||
|
"loadData": "Load data battle",
|
||||||
|
"exportData": "Export data battle",
|
||||||
|
"connectSetting": "Connection Setting",
|
||||||
|
"connected": "Connected",
|
||||||
|
"unconnected": "Unconnected",
|
||||||
|
"socketConnection": "Socket Connection",
|
||||||
|
"connectionType": "Connection Type",
|
||||||
|
"status": "Status",
|
||||||
|
"connect": "Connect",
|
||||||
|
"checkGameConnect": "Check game connection",
|
||||||
|
"other": "Other",
|
||||||
|
"host": "Host",
|
||||||
|
"port": "Port",
|
||||||
|
"hostPlaceHolder": "Enter Host",
|
||||||
|
"portPlaceHolder": "Enter port number",
|
||||||
|
"noDamageDetail": "No damage details available",
|
||||||
|
"noCharactersInLineup": "No characters in lineup",
|
||||||
|
"noTurns": "No turns yet",
|
||||||
|
"style": "Style",
|
||||||
|
"warrior": "Destruction",
|
||||||
|
"knight": "Preservation",
|
||||||
|
"mage": "Erudition",
|
||||||
|
"priest": "Abundance",
|
||||||
|
"rouge": "The Hunt",
|
||||||
|
"shaman": "Harmony",
|
||||||
|
"warlock": "Nihility",
|
||||||
|
"memory": "Remembrance",
|
||||||
|
"fire": "Fire",
|
||||||
|
"ice": "Ice",
|
||||||
|
"imaginary": "Imaginary",
|
||||||
|
"physical": "Physical",
|
||||||
|
"quantum": "Quantum",
|
||||||
|
"thunder": "Thunder",
|
||||||
|
"wind": "Wind"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
messages/jp.json
Normal file
75
messages/jp.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"TabTitle": {
|
||||||
|
"title": "Firefly Analytics",
|
||||||
|
"description": "Veritasの分析ツール"
|
||||||
|
},
|
||||||
|
"DataAnalysisPage": {
|
||||||
|
"useSkill": "スキル使用",
|
||||||
|
"totalDamage": "総ダメージ",
|
||||||
|
"damagePerAV": "AVあたりのダメージ",
|
||||||
|
"totalAV": "総AV",
|
||||||
|
"damagerPerCycle": "サイクルごとのダメージ",
|
||||||
|
"skillType": "スキルタイプ",
|
||||||
|
"skillName": "スキル名",
|
||||||
|
"actionValue": "アクション値",
|
||||||
|
"character": "キャラクター",
|
||||||
|
"id": "ID",
|
||||||
|
"path": "パス",
|
||||||
|
"damageByActionValue": "行動値によるダメージ",
|
||||||
|
"rarity": "レアリティ",
|
||||||
|
"element": "エレメンタル",
|
||||||
|
"totalTurn": "総ターン",
|
||||||
|
"technique": "テクニック",
|
||||||
|
"talent": "タレント",
|
||||||
|
"basic": "基本攻撃",
|
||||||
|
"skill": "スキル",
|
||||||
|
"ultimate": "アルティメット",
|
||||||
|
"servant": "サーヴァント",
|
||||||
|
"skillDamageBreakdown": "スキルダメージ内訳",
|
||||||
|
"skillUsageDistribution": "スキル使用分布",
|
||||||
|
"damageOverTime": "時間経過によるダメージ",
|
||||||
|
"damage": "ダメージ",
|
||||||
|
"cumulativeDamage": "累積ダメージ",
|
||||||
|
"characterInformation": "キャラクター情報",
|
||||||
|
"turnDetail": "ターン詳細",
|
||||||
|
"damageDetails": "ダメージ詳細",
|
||||||
|
"cycleCount": "サイクル数",
|
||||||
|
"chartInfo": "チャート情報",
|
||||||
|
"actionBar": "アクションタイムライン",
|
||||||
|
"lineupInfo": "編成情報",
|
||||||
|
"loadData": "バトルデータをロード",
|
||||||
|
"exportData": "バトルデータをエクスポート",
|
||||||
|
"connectSetting": "接続設定",
|
||||||
|
"connected": "接続済み",
|
||||||
|
"unconnected": "接続していない",
|
||||||
|
"socketConnection": "ソケット接続",
|
||||||
|
"connectionType": "接続タイプ",
|
||||||
|
"status": "ステータス",
|
||||||
|
"connect": "接続",
|
||||||
|
"checkGameConnect": "ゲーム接続を確認",
|
||||||
|
"other": "その他",
|
||||||
|
"host": "ホスト",
|
||||||
|
"port": "ポート",
|
||||||
|
"hostPlaceHolder": "ホストを入力",
|
||||||
|
"portPlaceHolder": "ポート番号を入力",
|
||||||
|
"noDamageDetail": "ダメージの詳細は利用できません",
|
||||||
|
"noCharactersInLineup": "編成にキャラクターがいません",
|
||||||
|
"noTurns": "まだターンがありません",
|
||||||
|
"style": "スタイル",
|
||||||
|
"warrior": "破壊",
|
||||||
|
"knight": "保護",
|
||||||
|
"mage": "博識",
|
||||||
|
"priest": "豊穣",
|
||||||
|
"rouge": "巡狩",
|
||||||
|
"shaman": "調和",
|
||||||
|
"warlock": "虚無",
|
||||||
|
"memory": "記憶",
|
||||||
|
"fire": "炎",
|
||||||
|
"ice": "氷",
|
||||||
|
"imaginary": "想像",
|
||||||
|
"physical": "物理",
|
||||||
|
"quantum": "量子",
|
||||||
|
"thunder": "雷",
|
||||||
|
"wind": "風"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
messages/kr.json
Normal file
75
messages/kr.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"TabTitle": {
|
||||||
|
"title": "Firefly 분석",
|
||||||
|
"description": "Veritas의 분석 도구"
|
||||||
|
},
|
||||||
|
"DataAnalysisPage": {
|
||||||
|
"useSkill": "스킬 사용",
|
||||||
|
"totalDamage": "총 피해",
|
||||||
|
"damagePerAV": "AV당 피해",
|
||||||
|
"totalAV": "총 AV",
|
||||||
|
"damagerPerCycle": "주기당 피해",
|
||||||
|
"skillType": "스킬 유형",
|
||||||
|
"skillName": "스킬 이름",
|
||||||
|
"actionValue": "액션 값",
|
||||||
|
"character": "캐릭터",
|
||||||
|
"id": "ID",
|
||||||
|
"path": "경로",
|
||||||
|
"rarity": "희귀도",
|
||||||
|
"damageByActionValue": "행동값에 따른 피해",
|
||||||
|
"element": "원소",
|
||||||
|
"totalTurn": "총 턴",
|
||||||
|
"technique": "기술",
|
||||||
|
"talent": "재능",
|
||||||
|
"basic": "기본 공격",
|
||||||
|
"skill": "스킬",
|
||||||
|
"ultimate": "궁극기",
|
||||||
|
"servant": "서번트",
|
||||||
|
"skillDamageBreakdown": "스킬 피해 분석",
|
||||||
|
"skillUsageDistribution": "스킬 사용 분포",
|
||||||
|
"damageOverTime": "시간 경과에 따른 피해",
|
||||||
|
"damage": "피해",
|
||||||
|
"cumulativeDamage": "누적 피해",
|
||||||
|
"characterInformation": "캐릭터 정보",
|
||||||
|
"turnDetail": "턴 상세",
|
||||||
|
"damageDetails": "피해 상세",
|
||||||
|
"cycleCount": "사이클 수",
|
||||||
|
"chartInfo": "차트 정보",
|
||||||
|
"actionBar": "액션 타임라인",
|
||||||
|
"lineupInfo": "라인업 정보",
|
||||||
|
"loadData": "전투 데이터 로드",
|
||||||
|
"exportData": "전투 데이터 내보내기",
|
||||||
|
"connectSetting": "연결 설정",
|
||||||
|
"connected": "연결됨",
|
||||||
|
"unconnected": "연결되지 않음",
|
||||||
|
"socketConnection": "소켓 연결",
|
||||||
|
"connectionType": "연결 유형",
|
||||||
|
"status": "상태",
|
||||||
|
"connect": "연결",
|
||||||
|
"checkGameConnect": "게임 연결 확인",
|
||||||
|
"other": "기타",
|
||||||
|
"host": "호스트",
|
||||||
|
"port": "포트",
|
||||||
|
"hostPlaceHolder": "호스트 입력",
|
||||||
|
"portPlaceHolder": "포트 번호 입력",
|
||||||
|
"noDamageDetail": "데미지 세부 정보가 없습니다",
|
||||||
|
"noCharactersInLineup": "라인업에 캐릭터가 없습니다",
|
||||||
|
"noTurns": "아직 턴이 없습니다",
|
||||||
|
"style": "스타일",
|
||||||
|
"warrior": "파괴",
|
||||||
|
"knight": "보존",
|
||||||
|
"mage": "지식",
|
||||||
|
"priest": "풍요",
|
||||||
|
"rouge": "사냥",
|
||||||
|
"shaman": "조화",
|
||||||
|
"warlock": "허무",
|
||||||
|
"memory": "기억",
|
||||||
|
"fire": "불",
|
||||||
|
"ice": "얼음",
|
||||||
|
"imaginary": "허수",
|
||||||
|
"physical": "물리",
|
||||||
|
"quantum": "양자",
|
||||||
|
"thunder": "번개",
|
||||||
|
"wind": "바람"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
messages/vi.json
Normal file
75
messages/vi.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"TabTitle": {
|
||||||
|
"title": "Firefly Analytics",
|
||||||
|
"description": "Công cụ phân tích cho Veritas"
|
||||||
|
},
|
||||||
|
"DataAnalysisPage": {
|
||||||
|
"useSkill": "Sử dụng kỹ năng",
|
||||||
|
"totalDamage": "Tổng sát thương",
|
||||||
|
"damagePerAV": "Sát thương mỗi AV",
|
||||||
|
"totalAV": "Tổng AV",
|
||||||
|
"damagerPerCycle": "Sát thương mỗi vòng",
|
||||||
|
"skillType": "Loại kỹ năng",
|
||||||
|
"skillName": "Tên kỹ năng",
|
||||||
|
"actionValue": "Giá trị hành động",
|
||||||
|
"character": "Nhân vật",
|
||||||
|
"id": "ID",
|
||||||
|
"path": "Đường dẫn",
|
||||||
|
"rarity": "Số sao",
|
||||||
|
"element": "Nguyên tố",
|
||||||
|
"totalTurn": "Tổng lượt",
|
||||||
|
"damageByActionValue": "Sát thương theo giá trị hành động",
|
||||||
|
"technique": "Bĩ kỹ",
|
||||||
|
"talent": "Thiên phú",
|
||||||
|
"basic": "Tấn công thường",
|
||||||
|
"skill": "Chiến kỹ",
|
||||||
|
"ultimate": "Tuyệt Kĩ",
|
||||||
|
"servant": "Vật triệu hồi",
|
||||||
|
"skillDamageBreakdown": "Phân tích sát thương kỹ năng",
|
||||||
|
"skillUsageDistribution": "Phân phối sử dụng kỹ năng",
|
||||||
|
"damageOverTime": "Sát thương theo thời gian",
|
||||||
|
"damage": "Sát thương",
|
||||||
|
"cumulativeDamage": "Sát thương tích lũy",
|
||||||
|
"characterInformation": "Thông tin nhân vật",
|
||||||
|
"turnDetail": "Chi tiết lượt",
|
||||||
|
"damageDetails": "Chi tiết sát thương",
|
||||||
|
"cycleCount": "Số vòng",
|
||||||
|
"chartInfo": "Thông tin biểu đồ",
|
||||||
|
"actionBar": "Thanh hành động",
|
||||||
|
"lineupInfo": "Thông tin đội hình",
|
||||||
|
"loadData": "Tải dữ liệu trận đấu",
|
||||||
|
"exportData": "Xuất dữ liệu trận đấu",
|
||||||
|
"connectSetting": "Cài đặt kết nối",
|
||||||
|
"connected": "Đã kết nối",
|
||||||
|
"unconnected": "Chưa kết nối",
|
||||||
|
"socketConnection": "Kết nối socket",
|
||||||
|
"connectionType": "Loại kết nối",
|
||||||
|
"status": "Trạng thái",
|
||||||
|
"connect": "Kết nối",
|
||||||
|
"checkGameConnect": "Kiểm tra kết nối game",
|
||||||
|
"other": "Khác",
|
||||||
|
"host": "Địa chỉ kết nối",
|
||||||
|
"port": "Cổng kết nối",
|
||||||
|
"hostPlaceHolder": "Nhập địa chỉ kết nối",
|
||||||
|
"portPlaceHolder": "Nhâp cổng kết nối",
|
||||||
|
"noDamageDetail": "Không có chi tiết sát thương",
|
||||||
|
"noCharactersInLineup": "Không có nhân vật trong đội hình",
|
||||||
|
"noTurns": "Chưa có lượt hành động",
|
||||||
|
"style": "Kiểu",
|
||||||
|
"warrior": "Hủy Diệt",
|
||||||
|
"knight": "Bảo Hộ",
|
||||||
|
"mage": "Tri Thức",
|
||||||
|
"priest": "Phong Phú",
|
||||||
|
"rouge": "Săn Bắn",
|
||||||
|
"shaman": "Hài Hòa",
|
||||||
|
"warlock": "Hư Vô",
|
||||||
|
"memory": "Ký Ức",
|
||||||
|
"fire": "Lửa",
|
||||||
|
"ice": "Băng",
|
||||||
|
"imaginary": "Số ảo",
|
||||||
|
"physical": "Vật Lý",
|
||||||
|
"quantum": "Lượng Tử",
|
||||||
|
"thunder": "Lôi",
|
||||||
|
"wind": "Phong"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
|
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin()
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/hakushin',
|
||||||
|
destination: 'https://api.hakush.in/hsr/data/character.json',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
878
package-lock.json
generated
878
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -9,19 +9,29 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.3",
|
||||||
|
"chart.js": "^4.4.9",
|
||||||
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
|
"framer-motion": "^12.7.4",
|
||||||
|
"next": "15.3.1",
|
||||||
|
"next-intl": "^4.0.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"next": "15.3.1"
|
"react-toastify": "^11.0.5",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
"daisyui": "^5.0.27",
|
||||||
"tailwindcss": "^4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.1",
|
"eslint-config-next": "15.3.1",
|
||||||
"@eslint/eslintrc": "^3"
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 195 KiB |
@@ -1,26 +1,4 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui" {
|
||||||
:root {
|
themes: winter --default, night --prefersdark, cupcake, coffee;
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/app/icon.png
Normal file
BIN
src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
@@ -1,6 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import Header from "../components/header";
|
||||||
|
import {ClientThemeWrapper, ThemeProvider} from "@/components/themeController";
|
||||||
|
import Footer from "@/components/footer";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -13,21 +19,35 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Firefly Analytics",
|
||||||
description: "Generated by create next app",
|
description: "Analytics tool for Veritas",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const messages = await getMessages();
|
||||||
|
const locale = await getLocale()
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{children}
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<ClientThemeWrapper>
|
||||||
|
<div className="h-full min-h-screen">
|
||||||
|
<Header></Header>
|
||||||
|
{children}
|
||||||
|
<Footer></Footer>
|
||||||
|
</div>
|
||||||
|
</ClientThemeWrapper>
|
||||||
|
</ThemeProvider>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
<ToastContainer/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
273
src/app/page.tsx
273
src/app/page.tsx
@@ -1,103 +1,184 @@
|
|||||||
import Image from "next/image";
|
"use client";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import ActionBar from "@/components/actionbar";
|
||||||
|
import useAvatarDataStore from "@/stores/avatarDataStore";
|
||||||
|
import { getCharacterListApi } from "@/lib/api";
|
||||||
|
import LineupBar from "@/components/lineupbar";
|
||||||
|
import useBattleDataStore from "@/stores/battleDataStore";
|
||||||
|
import DamagePerAvatarForAll from "@/components/chart/damagePerAvatarForAll";
|
||||||
|
import MultiCharLineChart from "@/components/chart/damageLineForAll";
|
||||||
|
import DamagePerCycleForAll from "@/components/chart/damagePerCycleForAll";
|
||||||
|
import DamagePercentChartForAll from "@/components/chart/damagePercentForAll";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
const transI18n = useTranslations("DataAnalysisPage");
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
const { setListAvatar } = useAvatarDataStore();
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
const {
|
||||||
<Image
|
totalAV,
|
||||||
className="dark:invert"
|
totalDamage,
|
||||||
src="/next.svg"
|
damagePerAV,
|
||||||
alt="Next.js logo"
|
} = useBattleDataStore();
|
||||||
width={180}
|
const [modeBar, setModeBar] = useState<0 | 1 | 2>(1);
|
||||||
height={38}
|
const [modeLine, setModeLine] = useState<0 | 1>(1);
|
||||||
priority
|
const [expandedCharts, setExpandedCharts] = useState<string[]>([]);
|
||||||
/>
|
|
||||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
const toggleExpand = (chartId: string) => {
|
||||||
<a
|
if (expandedCharts.includes(chartId.toLowerCase())) {
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
setExpandedCharts(expandedCharts.filter(id => id !== chartId.toLowerCase()));
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
} else {
|
||||||
target="_blank"
|
setExpandedCharts([...expandedCharts, chartId.toLowerCase()]);
|
||||||
rel="noopener noreferrer"
|
}
|
||||||
>
|
};
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
useEffect(() => {
|
||||||
src="/vercel.svg"
|
const fetchData = async () => {
|
||||||
alt="Vercel logomark"
|
const data = await getCharacterListApi();
|
||||||
width={20}
|
setListAvatar(data);
|
||||||
height={20}
|
};
|
||||||
/>
|
fetchData();
|
||||||
Deploy now
|
}, [setListAvatar]);
|
||||||
</a>
|
|
||||||
<a
|
useEffect(() => {
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
window.dispatchEvent(new Event('resize'));
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
}, [expandedCharts]);
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
return (
|
||||||
>
|
<div className="flex flex-col h-full w-full mt-5 min-h-[74vh] bg-base-100 font-[family-name:var(--font-geist-sans)]">
|
||||||
Read our docs
|
<div className="h-full">
|
||||||
</a>
|
<div className="grid grid-cols-12 gap-2 lg:gap-3 h-full min-h-full">
|
||||||
|
<div className="col-span-12 md:col-span-3 lg:col-span-2 xl:col-span-2 h-full">
|
||||||
|
<ActionBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 md:col-span-6 lg:col-span-8 xl:col-span-8 flex flex-col h-full">
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||||
|
<div className="p-2 text-base lg:text-lg xl:text-xl rounded bg-primary text-primary-content text-center shadow-md">
|
||||||
|
{transI18n("totalDamage")}: {Number(totalDamage).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className="p-2 text-base lg:text-lg xl:text-xl rounded bg-secondary text-secondary-content text-center shadow-md">
|
||||||
|
{transI18n("totalAV")}: {Number(totalAV).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className="p-2 text-base lg:text-lg xl:text-xl rounded bg-accent text-accent-content text-center shadow-md">
|
||||||
|
{transI18n("damagePerAV")}: {Number(damagePerAV).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-base-200 rounded-lg p-2 shadow-md flex-grow">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
{expandedCharts.includes('chart1') ? (
|
||||||
|
<div key="chart1-expanded" className="lg:col-span-2 bg-base-200 rounded-lg p-2 shadow-md relative">
|
||||||
|
<div className="absolute top-2 left-2 z-10">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-circle btn-ghost"
|
||||||
|
onClick={() => toggleExpand('chart1')}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DamagePerAvatarForAll />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key="chart1-normal" className="bg-base-200 rounded-lg p-2 shadow-md relative">
|
||||||
|
<div className="absolute top-2 left-2 z-10">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-circle btn-ghost"
|
||||||
|
onClick={() => toggleExpand('chart1')}
|
||||||
|
>
|
||||||
|
⤢
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DamagePerAvatarForAll />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`bg-base-200 rounded-lg p-2 shadow-md relative ${expandedCharts.includes('chart2') ? 'lg:col-span-2' : ''}`}
|
||||||
|
key={expandedCharts.includes('chart2') ? 'chart2-expanded' : 'chart2-normal'}
|
||||||
|
>
|
||||||
|
<div className="absolute top-2 left-2 z-10">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-circle btn-ghost"
|
||||||
|
onClick={() => toggleExpand('chart2')}
|
||||||
|
>
|
||||||
|
{expandedCharts.includes('chart2') ? '−' : '⤢'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{[0, 1].map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setModeLine(m as 0 | 1)}
|
||||||
|
className={`btn btn-sm ${modeLine === m ? "btn-accent" : "btn-ghost"}`}
|
||||||
|
>
|
||||||
|
{transI18n("style")} {m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<MultiCharLineChart mode={modeLine} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedCharts.includes('chart3') ? (
|
||||||
|
<div key="chart3-expanded" className="lg:col-span-2 bg-base-200 rounded-lg p-2 shadow-md relative">
|
||||||
|
<div className="absolute top-2 left-2 z-10">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-circle btn-ghost"
|
||||||
|
onClick={() => toggleExpand('chart3')}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DamagePercentChartForAll />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key="chart3-normal" className="bg-base-200 rounded-lg p-2 shadow-md relative">
|
||||||
|
<div className="absolute top-2 left-2 z-10">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-circle btn-ghost"
|
||||||
|
onClick={() => toggleExpand('chart3')}
|
||||||
|
>
|
||||||
|
⤢
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DamagePercentChartForAll />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`bg-base-200 rounded-lg p-2 shadow-md relative ${expandedCharts.includes('chart4') ? 'lg:col-span-2' : ''}`}
|
||||||
|
key={expandedCharts.includes('chart4') ? 'chart4-expanded' : 'chart4-normal'}
|
||||||
|
>
|
||||||
|
<div className="absolute top-2 left-2 z-10">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-circle btn-ghost"
|
||||||
|
onClick={() => toggleExpand('chart4')}
|
||||||
|
>
|
||||||
|
{expandedCharts.includes('chart4') ? '−' : '⤢'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{[0, 1, 2].map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setModeBar(m as 0 | 1 | 2)}
|
||||||
|
className={`btn btn-sm ${modeBar === m ? "btn-accent" : "btn-ghost"}`}
|
||||||
|
>
|
||||||
|
{transI18n("style")} {m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DamagePerCycleForAll mode={modeBar} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 md:col-span-3 lg:col-span-2 xl:col-span-2 h-full">
|
||||||
|
<LineupBar />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
260
src/components/actionbar/index.tsx
Normal file
260
src/components/actionbar/index.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"use client";
|
||||||
|
import useAvatarDataStore from "@/stores/avatarDataStore";
|
||||||
|
import useBattleDataStore from "@/stores/battleDataStore";
|
||||||
|
import useLocaleStore from "@/stores/localeStore";
|
||||||
|
import { AvatarType } from "@/types";
|
||||||
|
import { TurnBattleInfo } from "@/types/mics";
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { getNameChar } from "@/helper";
|
||||||
|
|
||||||
|
export default function ActionBar() {
|
||||||
|
const [selectTurn, setSelectTurn] = useState<TurnBattleInfo | null>(null);
|
||||||
|
const [selectAvatar, setSelectAvatar] = useState<AvatarType | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const { turnHistory } = useBattleDataStore();
|
||||||
|
const { listAvatar } = useAvatarDataStore();
|
||||||
|
const { locale } = useLocaleStore();
|
||||||
|
const transI18n = useTranslations("DataAnalysisPage");
|
||||||
|
const turnListRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const parallelogramStyle: React.CSSProperties = {
|
||||||
|
transform: 'skew(-9deg)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentStyle: React.CSSProperties = {
|
||||||
|
transform: 'skew(9deg)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShow = (modalId: string, avatar: AvatarType, turn: TurnBattleInfo) => {
|
||||||
|
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
|
||||||
|
if (modal) {
|
||||||
|
setSelectAvatar(avatar);
|
||||||
|
setSelectTurn(turn);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
modal.showModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close modal handler
|
||||||
|
const handleCloseModal = (modalId: string) => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectAvatar(null);
|
||||||
|
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
|
||||||
|
if (modal) {
|
||||||
|
modal.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle ESC key to close modal
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && isModalOpen) {
|
||||||
|
handleCloseModal("action_detail_modal");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleEscKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleEscKey);
|
||||||
|
}, [isModalOpen]);
|
||||||
|
|
||||||
|
// Scroll to the bottom when new turns are added
|
||||||
|
useEffect(() => {
|
||||||
|
if (turnListRef.current && turnHistory.length > 0) {
|
||||||
|
turnListRef.current.scrollTop = turnListRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [turnHistory.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 rounded-lg shadow-lg w-full h-full min-h-[74vh]">
|
||||||
|
<motion.h2
|
||||||
|
className="text-center text-xl lg:text-2xl mb-2 font-bold text-transparent bg-clip-text bg-gradient-to-r from-pink-500 via-purple-500 to-cyan-500"
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
{transI18n("actionBar")}
|
||||||
|
</motion.h2>
|
||||||
|
<div
|
||||||
|
ref={turnListRef}
|
||||||
|
className="flex px-2 w-full max-h-[90vh] pt-2 border-t-2 border-accent overflow-y-auto custom-scrollbar overflow-x-hidden"
|
||||||
|
>
|
||||||
|
<style jsx>{`
|
||||||
|
.custom-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--p)) hsl(var(--b3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: hsl(var(--b3));
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--p));
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--pf));
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-button {
|
||||||
|
display: none;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="w-full h-fit grid grid-cols-1 gap-1 ">
|
||||||
|
{turnHistory.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<p className="text-base-content opacity-50">{transI18n("noTurns")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
turnHistory.map((turn, index) => {
|
||||||
|
const data = listAvatar.find(it => it.id === turn.avatarId.toString());
|
||||||
|
if (!data) return null;
|
||||||
|
const text = getNameChar(locale, data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index}>
|
||||||
|
<div
|
||||||
|
onClick={() => handleShow("action_detail_modal", data, turn)}
|
||||||
|
style={parallelogramStyle}
|
||||||
|
className="flex border bg-base-100 w-full hover:bg-base-200 transition-colors duration-200 border-cyan-400 border-l-4 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={contentStyle}
|
||||||
|
className="flex flex-col items-center justify-center py-2 px-3"
|
||||||
|
>
|
||||||
|
<div className="avatar">
|
||||||
|
<div className="w-12 h-12 rounded-full border-2 flex items-center justify-center bg-base-300 border-cyan-400 border-l-4">
|
||||||
|
<img
|
||||||
|
src={`https://api.hakush.in/hsr/UI/avatarshopicon/${data.id}.webp`}
|
||||||
|
alt={text}
|
||||||
|
className="w-8 h-8 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-base-content text-sm mt-1 font-medium">{text}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center gap-2 px-3 py-2">
|
||||||
|
<div className="text-primary text-xs">
|
||||||
|
{`${transI18n("useSkill")}: ${turn.skillType}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-primary text-xs">
|
||||||
|
{`${transI18n("totalDamage")}: ${turn.totalDamage.toFixed(2)}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Character Detail Modal */}
|
||||||
|
<dialog id="action_detail_modal" className="modal modal-bottom sm:modal-middle backdrop-blur-sm">
|
||||||
|
<div className="modal-box w-11/12 max-w-7xl bg-base-100 text-base-content border border-purple-500/50 shadow-lg shadow-purple-500/20">
|
||||||
|
<div className="sticky top-0 z-10">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1, rotate: 90 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="btn btn-circle btn-md absolute right-2 top-2 bg-red-600 hover:bg-red-700 text-white border-none"
|
||||||
|
onClick={() => handleCloseModal("action_detail_modal")}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b border-purple-500/30 px-6 py-4 mb-4">
|
||||||
|
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-cyan-400">
|
||||||
|
{transI18n("turnDetail").toUpperCase()}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectAvatar && selectTurn && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="mt-4 w-full custom-scrollbar"
|
||||||
|
>
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md mb-4">
|
||||||
|
<h4 className="text-lg font-semibold mb-2 text-pink-500">{transI18n("characterInformation")}</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-base-content/70">{transI18n("id")}:</span>
|
||||||
|
<span className="font-bold">{selectAvatar.id}</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-base-content/70">{transI18n("character")}:</span>
|
||||||
|
<span className="font-bold">{getNameChar(locale, selectAvatar)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center items-center">
|
||||||
|
<img
|
||||||
|
src={`https://api.hakush.in/hsr/UI/avatarshopicon/${selectAvatar.id}.webp`}
|
||||||
|
alt={getNameChar(locale, selectAvatar)}
|
||||||
|
className="h-20 w-20 object-cover rounded-full border-2 border-purple-500 shadow-lg shadow-purple-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md">
|
||||||
|
<h4 className="text-lg font-semibold mb-2 text-cyan-500 border-b border-cyan-300/30 pb-1">{transI18n("skillType")}</h4>
|
||||||
|
<p className="mt-2">{transI18n(selectTurn?.skillType.toLowerCase())}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md">
|
||||||
|
<h4 className="text-lg font-semibold mb-2 text-cyan-500 border-b border-cyan-300/30 pb-1">{transI18n("skillName")}</h4>
|
||||||
|
<p className="mt-2">{selectTurn?.skillName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md">
|
||||||
|
<h4 className="text-lg font-semibold mb-2 text-cyan-500 border-b border-cyan-300/30 pb-1">{transI18n("actionValue")}</h4>
|
||||||
|
<p className="mt-2">{selectTurn?.actionValue.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md">
|
||||||
|
<h4 className="text-lg font-semibold mb-2 text-purple-500 border-b border-purple-300/30 pb-1">{transI18n("totalDamage")}</h4>
|
||||||
|
<p className="mt-2 font-bold text-lg">{selectTurn?.totalDamage.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md mb-4">
|
||||||
|
<h4 className="text-lg font-semibold mb-2 text-cyan-500 border-b border-cyan-300/30 pb-1">{transI18n("damageDetails")}</h4>
|
||||||
|
{selectTurn?.damageDetail && selectTurn.damageDetail.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 mt-3">
|
||||||
|
{selectTurn.damageDetail.map((detail, idx) => (
|
||||||
|
<p key={idx} className="py-1 px-2 rounded bg-base-300 text-sm">{detail.toFixed(2)}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 italic opacity-70">{transI18n("noDamageDetail")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/card/characterCard.tsx
Normal file
66
src/components/card/characterCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { getNameChar } from '@/helper';
|
||||||
|
import useLocaleStore from '@/stores/localeStore';
|
||||||
|
import { AvatarType } from '@/types';
|
||||||
|
|
||||||
|
interface CharacterCardProps {
|
||||||
|
data: AvatarType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRuby(text: string): string {
|
||||||
|
return text.replace(/\{RUBY_B#(.*?)\}(.*?)\{RUBY_E#\}/g, (_, furigana, kanji) => {
|
||||||
|
return `<ruby>${kanji}<rt>${furigana}</rt></ruby>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function CharacterCard({ data }: CharacterCardProps) {
|
||||||
|
const { locale } = useLocaleStore();
|
||||||
|
const text = getNameChar(locale, data)
|
||||||
|
return (
|
||||||
|
<li className="z-10 flex flex-col w-28 items-center p-1 rounded-md shadow-lg bg-gradient-to-b from-customStart to-customEnd transform transition-transform duration-300 hover:scale-105 m-1">
|
||||||
|
<div
|
||||||
|
className={`w-[80px] rounded-md p-[2px] bg-gradient-to-br ${
|
||||||
|
data.rank === "CombatPowerAvatarRarityType5"
|
||||||
|
? "from-yellow-400 via-yellow-300 to-yellow-500"
|
||||||
|
: "from-purple-300 via-purple-200 to-purple-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
src={`https://api.hakush.in/hsr/UI/avatarshopicon/${data.id}.webp`}
|
||||||
|
className="w-full h-full rounded-md object-cover"
|
||||||
|
alt="ALT"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
loading='lazy'
|
||||||
|
src={`https://api.hakush.in/hsr/UI/element/${data.damageType.toLowerCase()}.webp`}
|
||||||
|
className="absolute top-0 left-0 w-6 h-6"
|
||||||
|
alt={data.damageType.toLowerCase()}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
loading='lazy'
|
||||||
|
src={`https://api.hakush.in/hsr/UI/pathicon/${data.baseType.toLowerCase()}.webp`}
|
||||||
|
className="absolute top-0 right-0 w-6 h-6"
|
||||||
|
alt={data.baseType.toLowerCase()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{locale === "jp" ? (
|
||||||
|
<div
|
||||||
|
className="mt-2 text-center text-base font-normal leading-tight"
|
||||||
|
dangerouslySetInnerHTML={{ __html: parseRuby(text) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 text-center text-base font-normal leading-tight">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/components/chart/damageLineForAll.tsx
Normal file
65
src/components/chart/damageLineForAll.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
LineElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { useDamageLinesForAll } from '@/hooks/useDamageLinesForAll';
|
||||||
|
import useAvatarDataStore from '@/stores/avatarDataStore';
|
||||||
|
import useLocaleStore from '@/stores/localeStore';
|
||||||
|
import { getNameChar } from '@/helper';
|
||||||
|
|
||||||
|
ChartJS.register(LineElement, CategoryScale, LinearScale, PointElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
const colors = ['#f87171', '#34d399', '#60a5fa', '#facc15', '#a78bfa', '#fb923c'];
|
||||||
|
|
||||||
|
export default function MultiCharLineChart({ mode = 0 }: { mode?: 0 | 1 }) {
|
||||||
|
const dataByAvatar = useDamageLinesForAll(mode);
|
||||||
|
const avatarIds = Object.keys(dataByAvatar).map(Number);
|
||||||
|
const { listAvatar } = useAvatarDataStore()
|
||||||
|
const { locale } = useLocaleStore();
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
datasets: avatarIds.map((id, idx) => ({
|
||||||
|
label: getNameChar(locale, listAvatar.find(it => it.id == id.toString())),
|
||||||
|
data: dataByAvatar[id].map(({ x, y }: { x: number; y: number }) => {
|
||||||
|
return {
|
||||||
|
x: x.toFixed(2),
|
||||||
|
y: y.toFixed(2)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
borderColor: colors[idx % colors.length],
|
||||||
|
backgroundColor: colors[idx % colors.length],
|
||||||
|
fill: false,
|
||||||
|
tension: 0.3,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear' as const,
|
||||||
|
position: 'bottom' as "bottom" | "center" | "left" | "top" | "right",
|
||||||
|
ticks: {
|
||||||
|
stepSize: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
datalabels: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Line data={data} options={options} />;
|
||||||
|
}
|
||||||
67
src/components/chart/damageLineForOne.tsx
Normal file
67
src/components/chart/damageLineForOne.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
import { Line } from "react-chartjs-2";
|
||||||
|
import { ChartOptions } from "chart.js";
|
||||||
|
import { useDamageLineForOne } from "@/hooks/useDamageLineForOne";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
} from "chart.js";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
|
ChartJS.defaults.set('plugins.datalabels', {
|
||||||
|
color: '#FE777B'
|
||||||
|
});
|
||||||
|
|
||||||
|
export function DamageLineForOne({ avatarId, mode = 0 }: { avatarId: number; mode?: 0 | 1 }) {
|
||||||
|
const dataRaw = useDamageLineForOne(avatarId, mode);
|
||||||
|
const transI18n = useTranslations("DataAnalysisPage");
|
||||||
|
const data = {
|
||||||
|
labels: dataRaw.map(d => Number(d.x.toFixed(2))),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: mode === 1 ? transI18n("cumulativeDamage") : transI18n("damageByActionValue"),
|
||||||
|
data: dataRaw.map(d => Number(d.y.toFixed(2))),
|
||||||
|
borderColor: "rgba(75,192,192,1)",
|
||||||
|
backgroundColor: "rgba(75,192,192,0.2)",
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: ChartOptions<"line"> = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
datalabels: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { title: { display: true, text: transI18n("actionValue") } },
|
||||||
|
y: { title: { display: true, text: transI18n("damage") }, beginAtZero: true },
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Line data={data} options={options} />;
|
||||||
|
}
|
||||||
80
src/components/chart/damagePerAvatarForAll.tsx
Normal file
80
src/components/chart/damagePerAvatarForAll.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
import { getNameChar } from '@/helper';
|
||||||
|
import useAvatarDataStore from '@/stores/avatarDataStore';
|
||||||
|
import useBattleDataStore from '@/stores/battleDataStore';
|
||||||
|
import useLocaleStore from '@/stores/localeStore';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Bar } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
|
||||||
|
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend);
|
||||||
|
|
||||||
|
const colorPalette = [
|
||||||
|
'rgba(255, 99, 132, 0.6)',
|
||||||
|
'rgba(54, 162, 235, 0.6)',
|
||||||
|
'rgba(255, 206, 86, 0.6)',
|
||||||
|
'rgba(75, 192, 192, 0.6)',
|
||||||
|
'rgba(153, 102, 255, 0.6)',
|
||||||
|
'rgba(255, 159, 64, 0.6)',
|
||||||
|
'rgba(199, 199, 199, 0.6)',
|
||||||
|
];
|
||||||
|
|
||||||
|
const borderPalette = colorPalette.map((color) => color.replace('0.6', '1'));
|
||||||
|
|
||||||
|
export default function DamagePerAvatarForAll() {
|
||||||
|
const transI18n = useTranslations("DataAnalysisPage");
|
||||||
|
const { lineup, turnHistory } = useBattleDataStore()
|
||||||
|
const { listAvatar } = useAvatarDataStore()
|
||||||
|
const { locale } = useLocaleStore();
|
||||||
|
const damageByAvatar = lineup.map((avatar) => {
|
||||||
|
const turns = turnHistory.filter((turn) => turn.avatarId === avatar.avatarId);
|
||||||
|
const totalDmg = turns.reduce((sum, turn) => sum + turn.totalDamage, 0);
|
||||||
|
const char = listAvatar.find(it => it.id === avatar.avatarId.toString())
|
||||||
|
if (!char) return
|
||||||
|
return {
|
||||||
|
avatarId: avatar.avatarId,
|
||||||
|
damage: totalDmg,
|
||||||
|
avatarName: getNameChar(locale, char)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: damageByAvatar.map((item) => item?.avatarName),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: transI18n("totalDamage"),
|
||||||
|
data: damageByAvatar.map((item) => item?.damage),
|
||||||
|
backgroundColor: damageByAvatar.map((_, idx) => colorPalette[idx % colorPalette.length]),
|
||||||
|
borderColor: damageByAvatar.map((_, idx) => borderPalette[idx % borderPalette.length]),
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
precision: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
datalabels: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Bar data={data} options={options} />;
|
||||||
|
}
|
||||||
44
src/components/chart/damagePerCycleForAll.tsx
Normal file
44
src/components/chart/damagePerCycleForAll.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
import { Bar } from "react-chartjs-2";
|
||||||
|
import { ChartOptions } from "chart.js";
|
||||||
|
import { useDamagePerCycleForAll } from "@/hooks/useDamagePerCycle";
|
||||||
|
|
||||||
|
import { Chart as ChartJS, BarElement, ArcElement, RadialLinearScale } from "chart.js";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
ChartJS.register(BarElement, ArcElement, RadialLinearScale);
|
||||||
|
export default function DamagePerCycleForAll({
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
mode: 0 | 1 | 2;
|
||||||
|
}) {
|
||||||
|
const dataRaw = useDamagePerCycleForAll(mode);
|
||||||
|
const transI18n = useTranslations("DataAnalysisPage");
|
||||||
|
const data = {
|
||||||
|
labels: dataRaw.map(d => d.x),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: mode === 0 ? `${transI18n("damagerPerCycle")} (100av)` : mode === 1 ? `${transI18n("damagerPerCycle")} (150av | 100v)` : `${transI18n("damagerPerCycle")} (150av | 150av | 100v)`,
|
||||||
|
data: dataRaw.map(d => d.y),
|
||||||
|
backgroundColor: "rgba(255,99,132,0.6)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: ChartOptions<"bar"> = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
datalabels: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { title: { display: true, text: transI18n("cycleCount") } },
|
||||||
|
y: { title: { display: true, text: transI18n("totalDamage") }, beginAtZero: true },
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Bar data={data} options={options} />;
|
||||||
|
}
|
||||||
46
src/components/chart/damagePerCycleForOne.tsx
Normal file
46
src/components/chart/damagePerCycleForOne.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
import { Bar } from "react-chartjs-2";
|
||||||
|
import { ChartOptions } from "chart.js";
|
||||||
|
import { useDamagePerCycleForOne } from "@/hooks/useDamagePerCycle";
|
||||||
|
|
||||||
|
import { Chart as ChartJS, BarElement, ArcElement, RadialLinearScale } from "chart.js";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
ChartJS.register(BarElement, ArcElement, RadialLinearScale);
|
||||||
|
export function DamagePerCycleForOne({
|
||||||
|
avatarId,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
avatarId: number;
|
||||||
|
mode: 0 | 1 | 2;
|
||||||
|
}) {
|
||||||
|
const dataRaw = useDamagePerCycleForOne(avatarId, mode);
|
||||||
|
const transI18n = useTranslations("DataAnalysisPage");
|
||||||
|
const data = {
|
||||||
|
labels: dataRaw.map(d => d.x),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: mode === 0 ? `${transI18n("damagerPerCycle")} (100av)` : mode === 1 ? `${transI18n("damagerPerCycle")} (150av | 100v)` : `${transI18n("damagerPerCycle")} (150av | 150av | 100v)`,
|
||||||
|
data: dataRaw.map(d => d.y),
|
||||||
|
backgroundColor: "rgba(255,99,132,0.6)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: ChartOptions<"bar"> = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
datalabels: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { title: { display: true, text: transI18n("cycleCount") } },
|
||||||
|
y: { title: { display: true, text: transI18n("totalDamage") }, beginAtZero: true },
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Bar data={data} options={options} />;
|
||||||
|
}
|
||||||
51
src/components/chart/damagePercentForAll.tsx
Normal file
51
src/components/chart/damagePercentForAll.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
ArcElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Pie } from 'react-chartjs-2';
|
||||||
|
import { useDamagePercentPerAvatar } from '@/hooks/useDamagePercentPerAvatar';
|
||||||
|
import useAvatarDataStore from '@/stores/avatarDataStore';
|
||||||
|
import useLocaleStore from '@/stores/localeStore';
|
||||||
|
import { getNameChar } from '@/helper';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
const colors = ['#f87171', '#34d399', '#60a5fa', '#facc15', '#a78bfa', '#fb923c', '#f472b6'];
|
||||||
|
|
||||||
|
export default function DamagePercentChartForAll() {
|
||||||
|
const data = useDamagePercentPerAvatar();
|
||||||
|
const { listAvatar } = useAvatarDataStore()
|
||||||
|
const { locale } = useLocaleStore();
|
||||||
|
const transI18n = useTranslations("DataAnalysisPage");
|
||||||
|
const chartData = {
|
||||||
|
labels: data.map(d => getNameChar(locale, listAvatar.find(it => it.id == d.avatarId.toString()))),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '% ' + transI18n("damage"),
|
||||||
|
data: data.map(d => d.percent.toFixed(2)),
|
||||||
|
backgroundColor: colors,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx: import('chart.js').TooltipItem<'pie'>) => `${ctx.label}: ${ctx.raw}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
datalabels: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Pie data={chartData} options={options} />;
|
||||||
|
}
|
||||||
58
src/components/chart/skillBarChart.tsx
Normal file
58
src/components/chart/skillBarChart.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
import { getNameChar } from '@/helper';
|
||||||
|
import { useSkillDamageByAvatar } from '@/hooks/useSkillDamageStats';
|
||||||
|
import useAvatarDataStore from '@/stores/avatarDataStore';
|
||||||
|
import useLocaleStore from '@/stores/localeStore';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Bar } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, ChartDataLabels);
|
||||||
|
|
||||||
|
export default function SkillBarChart({ avatarId }: { avatarId: number }) {
|
||||||
|
const { labels, damageValues } = useSkillDamageByAvatar(avatarId);
|
||||||
|
const transI18n = useTranslations("DataAnalysisPage");
|
||||||
|
const colors = ['#f87171', '#facc15', '#34d399', '#60a5fa', '#a78bfa', '#fb923c'];
|
||||||
|
const { listAvatar } = useAvatarDataStore()
|
||||||
|
const { locale } = useLocaleStore();
|
||||||
|
const data = {
|
||||||
|
labels: labels.map(it => transI18n(it.toLowerCase())),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: getNameChar(locale, listAvatar.find(it => it.id === avatarId.toString())),
|
||||||
|
data: damageValues,
|
||||||
|
backgroundColor: colors.slice(0, labels.length),
|
||||||
|
borderColor: '#111',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { precision: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
datalabels: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Bar data={data} options={options} />;
|
||||||
|
}
|
||||||
59
src/components/chart/skillPieChart.tsx
Normal file
59
src/components/chart/skillPieChart.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
import { useSkillDamageByAvatar } from '@/hooks/useSkillDamageStats';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
ArcElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Pie } from 'react-chartjs-2';
|
||||||
|
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip, Legend, ChartDataLabels);
|
||||||
|
|
||||||
|
export default function SkillPieChart({ avatarId }: { avatarId: number }) {
|
||||||
|
const { labels, damageValues } = useSkillDamageByAvatar(avatarId);
|
||||||
|
const transI18n = useTranslations("DataAnalysisPage");
|
||||||
|
const total = damageValues.reduce((sum, val) => sum + val, 0);
|
||||||
|
|
||||||
|
const labelsWithPercent = labels.map((label, index) => {
|
||||||
|
const value = damageValues[index];
|
||||||
|
const percent = total ? ((value / total) * 100).toFixed(1) : '0.0';
|
||||||
|
return `${transI18n(label.toLowerCase())} (${percent}%)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: labelsWithPercent,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: damageValues,
|
||||||
|
backgroundColor: [
|
||||||
|
'#f87171', // red
|
||||||
|
'#facc15', // yellow
|
||||||
|
'#34d399', // green
|
||||||
|
'#60a5fa', // blue
|
||||||
|
'#a78bfa', // purple
|
||||||
|
'#fb923c', // orange
|
||||||
|
],
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
options={{
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'right' },
|
||||||
|
datalabels: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/components/footer/index.tsx
Normal file
11
src/components/footer/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return(
|
||||||
|
<footer className="footer footer-horizontal footer-center bg-base-200 text-base-content rounded p-10">
|
||||||
|
|
||||||
|
<aside>
|
||||||
|
<p>Copyright © {new Date().getFullYear()} - Kain (Powered by Nextjs & DaisyUi)</p>
|
||||||
|
</aside>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
459
src/components/header/index.tsx
Normal file
459
src/components/header/index.tsx
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
"use client"
|
||||||
|
import { exportBattleData, importBattleData } from "@/helper";
|
||||||
|
import { useChangeTheme } from "@/hooks/useChangeTheme";
|
||||||
|
import { checkConnectTcpApi } from "@/lib/api";
|
||||||
|
import { connectSocket, disconnectSocket, getSocket, isSocketConnected } from "@/lib/socket";
|
||||||
|
import useBattleDataStore from "@/stores/battleDataStore";
|
||||||
|
import useLocaleStore from "@/stores/localeStore";
|
||||||
|
import useSocketStore from "@/stores/socketSettingStore";
|
||||||
|
import { BattleDataStateJson } from "@/types/mics";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
{ label: "Winter" },
|
||||||
|
{ label: "Night" },
|
||||||
|
{ label: "Cupcake" },
|
||||||
|
{ label: "Coffee" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const { changeTheme } = useChangeTheme()
|
||||||
|
const { locale, setLocale } = useLocaleStore()
|
||||||
|
const {
|
||||||
|
totalAV,
|
||||||
|
totalDamage,
|
||||||
|
damagePerAV,
|
||||||
|
turnHistory,
|
||||||
|
lineup,
|
||||||
|
loadBattleDataFromJSON,
|
||||||
|
} = useBattleDataStore()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const transI18n = useTranslations("DataAnalysisPage")
|
||||||
|
const { host, port, status, connectionType, setHost, setPort, setStatus, setConnectionType } = useSocketStore();
|
||||||
|
const [message, setMessage] = useState({ text: '', type: '' });
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cookieLocale = document.cookie.split("; ")
|
||||||
|
.find((row) => row.startsWith("MYNEXTAPP_LOCALE"))
|
||||||
|
?.split("=")[1];
|
||||||
|
|
||||||
|
if (cookieLocale) {
|
||||||
|
setLocale(cookieLocale)
|
||||||
|
} else {
|
||||||
|
const browserLocale = navigator.language.slice(0, 2);
|
||||||
|
setLocale(browserLocale);
|
||||||
|
document.cookie = `MYNEXTAPP_LOCALE=${browserLocale};`
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}, [router, setLocale])
|
||||||
|
|
||||||
|
const changeLocale = (newLocale: string) => {
|
||||||
|
setLocale(newLocale)
|
||||||
|
document.cookie = `MYNEXTAPP_LOCALE=${newLocale};`
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkStatus = () => {
|
||||||
|
const connected = isSocketConnected();
|
||||||
|
if (connected !== status) {
|
||||||
|
setStatus(connected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkStatus();
|
||||||
|
const intervalId = setInterval(checkStatus, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [status, setStatus]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket();
|
||||||
|
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disconnectSocket()
|
||||||
|
};
|
||||||
|
}, [setStatus]);
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
if (!host || !port) {
|
||||||
|
setMessage({ text: 'IP and Port are required', type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectSocket();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const connected = isSocketConnected();
|
||||||
|
setStatus(connected);
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
setMessage({ text: 'Socket connected successfully!', type: 'success' });
|
||||||
|
} else {
|
||||||
|
setMessage({ text: 'Failed to connect. Please check IP and port.', type: 'error' });
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkConnection = async () => {
|
||||||
|
setMessage({ text: 'Checking connection...', type: 'info' });
|
||||||
|
let isConnect = false
|
||||||
|
try {
|
||||||
|
isConnect = await checkConnectTcpApi()
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConnect) {
|
||||||
|
setMessage({ text: 'Connection bridge to TCP Server failed', type: 'error' })
|
||||||
|
} else {
|
||||||
|
setMessage({ text: 'Connection OK!', type: 'success' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShow = (modalId: string) => {
|
||||||
|
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
|
||||||
|
if (modal) {
|
||||||
|
setIsModalOpen(true);
|
||||||
|
modal.showModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close modal handler
|
||||||
|
const handleCloseModal = (modalId: string) => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
|
||||||
|
if (modal) {
|
||||||
|
modal.close()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle ESC key to close modal
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && isModalOpen) {
|
||||||
|
handleCloseModal("character_detail_modal");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleEscKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleEscKey);
|
||||||
|
}, [isModalOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="navbar bg-base-100 shadow-md sticky top-0 z-50 px-3 py-1">
|
||||||
|
<div className="navbar-start">
|
||||||
|
{/* Mobile menu dropdown */}
|
||||||
|
<div className="dropdown">
|
||||||
|
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm lg:hidden hover:bg-base-200 transition-all duration-300">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h8m-8 6h16" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
tabIndex={0}
|
||||||
|
className="menu menu-sm dropdown-content bg-base-100 rounded-box z-10 mt-3 w-52 p-2 shadow-md border border-base-200"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
|
||||||
|
>
|
||||||
|
{transI18n("loadData")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
|
||||||
|
>
|
||||||
|
{transI18n("exportData")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
|
||||||
|
onClick={() => handleShow("socket_connection_modal")}
|
||||||
|
>
|
||||||
|
{transI18n("connectSetting")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
|
||||||
|
<a className=" flex flex-col items-start text-left gap-0 hover:scale-105 px-2">
|
||||||
|
<h1 className="text-xl font-bold">
|
||||||
|
<span className="text-emerald-500">Firefly Analy</span>
|
||||||
|
<span className="bg-clip-text text-transparent bg-gradient-to-r from-emerald-400 via-orange-500 to-red-500">
|
||||||
|
sis
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500">For Veritas</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop navigation */}
|
||||||
|
<div className="navbar-center hidden lg:flex">
|
||||||
|
<ul className="menu menu-horizontal gap-1">
|
||||||
|
<li>
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
id="battle-data-upload"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
importBattleData(file, loadBattleDataFromJSON)
|
||||||
|
.then(() => console.log('Data loaded'))
|
||||||
|
.catch(err => alert('Failed to load data: ' + err.message));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
|
||||||
|
onClick={() => document.getElementById('battle-data-upload')?.click()}
|
||||||
|
>
|
||||||
|
{transI18n("loadData")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
|
||||||
|
onClick={() => exportBattleData({ totalAV, totalDamage, turnHistory, damagePerAV, lineup } as BattleDataStateJson)}
|
||||||
|
>
|
||||||
|
{transI18n("exportData")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
|
||||||
|
onClick={() => handleShow("socket_connection_modal")}
|
||||||
|
>
|
||||||
|
{transI18n("connectSetting")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Right side items */}
|
||||||
|
<div className="navbar-end gap-2">
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="flex items-center space-x-2 p-1.5 rounded-full shadow-md">
|
||||||
|
<div className={`text-sm italic ${status ? 'text-green-500' : 'text-red-500'}`}>
|
||||||
|
{status ? transI18n("connected") : transI18n("unconnected")}
|
||||||
|
</div>
|
||||||
|
<div className={`w-3 h-3 rounded-full ${status ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Language selector - REFINED */}
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<div className="flex items-center gap-1 border border-base-300 rounded text-sm px-1.5 py-0.5 hover:bg-base-200 cursor-pointer transition-all duration-200">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m10.5 21 5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 0 1 6-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 0 1-3.827-5.802" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="outline-none bg-base-200 cursor-pointer text-sm pr-0"
|
||||||
|
value={locale}
|
||||||
|
onChange={(e) => changeLocale(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="vi">VI</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="cn">CN</option>
|
||||||
|
<option value="jp">JP</option>
|
||||||
|
<option value="kr">KR</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div title="Change Theme" className="dropdown dropdown-end">
|
||||||
|
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm hover:bg-base-200 transition-all duration-200 px-2">
|
||||||
|
<svg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-4 w-4 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
width="10px"
|
||||||
|
height="10px"
|
||||||
|
className="ml-1 h-2 w-2 fill-current opacity-60"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 2048 2048"
|
||||||
|
>
|
||||||
|
<path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
className="dropdown-content bg-base-100 text-base-content rounded-lg top-px overflow-y-auto border border-base-200 shadow-md mt-12"
|
||||||
|
>
|
||||||
|
<ul className="menu w-40 p-1">
|
||||||
|
{themes.map((theme) => (
|
||||||
|
<li
|
||||||
|
key={theme.label}
|
||||||
|
onClick={() => {
|
||||||
|
if (changeTheme) changeTheme(theme.label.toLowerCase());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="gap-2 px-2 py-1.5 hover:bg-base-200 rounded text-sm transition-all duration-200"
|
||||||
|
data-set-theme={theme.label.toLowerCase()}
|
||||||
|
data-act-class="[&_svg]:visible"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-theme={theme.label.toLowerCase()}
|
||||||
|
className="bg-base-100 grid shrink-0 grid-cols-2 gap-0.5 rounded p-1 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="bg-base-content size-1 rounded-full" />
|
||||||
|
<div className="bg-primary size-1 rounded-full" />
|
||||||
|
<div className="bg-secondary size-1 rounded-full" />
|
||||||
|
<div className="bg-accent size-1 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{theme.label}</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub Link */}
|
||||||
|
<Link
|
||||||
|
className='btn btn-ghost btn-sm btn-circle hover:bg-base-200 transition-all duration-200'
|
||||||
|
href={"https://github.com/AzenKain/SR-Analysis"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512">
|
||||||
|
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="socket_connection_modal" className="modal sm:modal-middle backdrop-blur-sm">
|
||||||
|
<div className="modal-box w-11/12 max-w-7xl bg-base-100 text-base-content border border-purple-500/50 shadow-lg shadow-purple-500/20">
|
||||||
|
<div className="sticky top-0 z-10">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1, rotate: 90 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="btn btn-circle btn-md absolute right-2 top-2 bg-red-600 hover:bg-red-700 text-white border-none"
|
||||||
|
onClick={() => handleCloseModal("socket_connection_modal")}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b border-purple-500/30 px-6 py-4 mb-4">
|
||||||
|
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-cyan-400">
|
||||||
|
{transI18n("socketConnection").toUpperCase()}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
{/* Select connection type */}
|
||||||
|
<div className="form-control grid grid-cols-1 w-full mb-6">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold text-purple-300">{transI18n("connectionType")}</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered border-purple-500/30 focus:border-purple-500 bg-base-200"
|
||||||
|
value={connectionType}
|
||||||
|
onChange={(e) => setConnectionType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="FireflyPSLocal">Firefly PS Local</option>
|
||||||
|
<option value="Other">{transI18n("other")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show host/port if Other */}
|
||||||
|
{connectionType === "Other" && (
|
||||||
|
<div className="flex flex-col md:flex-row md:space-x-4 mb-6">
|
||||||
|
<div className="form-control w-full mb-4 md:mb-0">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold text-purple-300">{transI18n("host")}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={transI18n("hostPlaceHolder")}
|
||||||
|
className="input input-bordered w-full border-purple-500/30 focus:border-purple-500 bg-base-200"
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control w-full">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold text-purple-300">{transI18n("port")}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder={transI18n("portPlaceHolder")}
|
||||||
|
className="input input-bordered w-full border-purple-500/30 focus:border-purple-500 bg-base-200"
|
||||||
|
value={port || ''}
|
||||||
|
onChange={(e) => setPort(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.text && (
|
||||||
|
<div className={`alert ${message.type === 'success' ? 'alert-success' :
|
||||||
|
message.type === 'error' ? 'alert-error' : 'alert-info'
|
||||||
|
} mb-6`}>
|
||||||
|
<span>{message.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-center mt-6 mb-2">
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<span className="text-md mr-2">{transI18n("status")}:</span>
|
||||||
|
<span className={`badge ${status ? 'badge-success' : 'badge-error'} badge-lg`}>
|
||||||
|
{status ? transI18n("connected") : transI18n("unconnected")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
className={`btn btn-primary`}
|
||||||
|
onClick={handleConnect}
|
||||||
|
>
|
||||||
|
{transI18n("connect")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn btn-secondary`}
|
||||||
|
onClick={checkConnection}
|
||||||
|
>
|
||||||
|
{transI18n("checkGameConnect")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
274
src/components/lineupbar/index.tsx
Normal file
274
src/components/lineupbar/index.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
import useAvatarDataStore from "@/stores/avatarDataStore";
|
||||||
|
import useBattleDataStore from "@/stores/battleDataStore";
|
||||||
|
import CharacterCard from "../card/characterCard";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { AvatarType } from "@/types";
|
||||||
|
import useLocaleStore from "@/stores/localeStore";
|
||||||
|
import { getNameChar } from '@/helper/getNameChar';
|
||||||
|
import SkillBarChart from "../chart/skillBarChart";
|
||||||
|
import SkillPieChart from "../chart/skillPieChart";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { DamageLineForOne } from "../chart/damageLineForOne";
|
||||||
|
import { DamagePerCycleForOne } from "../chart/damagePerCycleForOne";
|
||||||
|
import { useCalcTotalDmgAvatar, useCalcTotalTurnAvatar } from "@/hooks/useCalcAvatarData";
|
||||||
|
|
||||||
|
export default function LineupBar() {
|
||||||
|
const [selectedCharacter, setSelectedCharacter] = useState<AvatarType | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const transI18n = useTranslations("DataAnalysisPage");
|
||||||
|
const { lineup } = useBattleDataStore();
|
||||||
|
const { listAvatar } = useAvatarDataStore();
|
||||||
|
const { locale } = useLocaleStore();
|
||||||
|
const [modeBar, setModeBar] = useState<0 | 1 | 2>(1);
|
||||||
|
const [modeLine, setModeLine] = useState<0 | 1>(1);
|
||||||
|
|
||||||
|
|
||||||
|
const totalDamage = useCalcTotalDmgAvatar(selectedCharacter ? Number(selectedCharacter.id) : 0);
|
||||||
|
const totalTurn = useCalcTotalTurnAvatar(selectedCharacter ? Number(selectedCharacter.id) : 0)
|
||||||
|
|
||||||
|
|
||||||
|
const lineupAvatars = listAvatar.filter(item =>
|
||||||
|
lineup.some(av => av.avatarId.toString() === item.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShow = (modalId: string, item: AvatarType) => {
|
||||||
|
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
|
||||||
|
if (modal) {
|
||||||
|
setSelectedCharacter(item);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
modal.showModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close modal handler
|
||||||
|
const handleCloseModal = (modalId: string) => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedCharacter(null);
|
||||||
|
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
|
||||||
|
if (modal) {
|
||||||
|
modal.close()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle ESC key to close modal
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && isModalOpen) {
|
||||||
|
handleCloseModal("character_detail_modal");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleEscKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleEscKey);
|
||||||
|
}, [isModalOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 rounded-lg shadow-lg w-full h-full min-h-[74vh]">
|
||||||
|
<motion.h2
|
||||||
|
className="text-center text-xl lg:text-2xl pb-2 font-bold text-transparent bg-clip-text bg-gradient-to-r from-pink-500 via-purple-500 to-cyan-500"
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
{transI18n("lineupInfo")}
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<div className="relative h-full pt-2 max-h-[90vh] border-t-2 border-accent">
|
||||||
|
{lineupAvatars.length === 0 ? (
|
||||||
|
<div className="h-full flex justify-center items-start">
|
||||||
|
<p className="text-base-content opacity-50">{transI18n("noCharactersInLineup")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full overflow-y-auto overflow-x-hidden custom-scrollbar rounded-lg">
|
||||||
|
<style jsx>{`
|
||||||
|
.custom-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--p)) hsl(var(--b3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: hsl(var(--b3));
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--p));
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--pf));
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-button {
|
||||||
|
display: none;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="grid grid-cols-1 w-full justify-items-center items-start">
|
||||||
|
{lineupAvatars.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.id}
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => handleShow("character_detail_modal", item)}
|
||||||
|
>
|
||||||
|
<CharacterCard data={item} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Character Detail Modal */}
|
||||||
|
<dialog id="character_detail_modal" className="modal backdrop-blur-sm">
|
||||||
|
<div className="modal-box w-11/12 max-w-7xl bg-gradient-to-b bg-base-100 text-base-content border-purple-500 shadow-lg shadow-purple-500/20">
|
||||||
|
<div className="sticky top-0 z-10">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1, rotate: 90 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="btn btn-circle btn-md absolute right-2 top-2 bg-red-600 hover:bg-red-700 text-white border-none"
|
||||||
|
onClick={() => handleCloseModal("character_detail_modal")}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b border-purple-500/30 px-6 py-4 mb-4">
|
||||||
|
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-cyan-400">
|
||||||
|
{selectedCharacter ? getNameChar(locale, selectedCharacter).toUpperCase() : ""}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedCharacter && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 gap-6 p-2"
|
||||||
|
>
|
||||||
|
<div className="md:col-span-2 bg-base-200 rounded-lg p-4 shadow-md">
|
||||||
|
<h4 className="text-lg font-semibold mb-2 text-pink-500">{transI18n("characterInformation")}</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col space-y-2 relative">
|
||||||
|
<p>
|
||||||
|
{transI18n("id")}: <span className="font-bold">{selectedCharacter.id}</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center space-x-2">
|
||||||
|
<span>{transI18n("path")}:</span>
|
||||||
|
<span className="font-bold">{transI18n(selectedCharacter.baseType.toLowerCase())}</span>
|
||||||
|
{selectedCharacter.baseType && (
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
src={`https://api.hakush.in/hsr/UI/pathicon/${selectedCharacter.baseType.toLowerCase()}.webp`}
|
||||||
|
className="w-6 h-6"
|
||||||
|
alt={selectedCharacter.baseType.toLowerCase()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{transI18n("rarity")}: <span className="font-bold">{selectedCharacter.rank === "CombatPowerAvatarRarityType5" ? "5*" : "4*"}</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center space-x-2">
|
||||||
|
<span>{transI18n("element")}:</span>
|
||||||
|
<span className="font-bold">{transI18n(selectedCharacter.damageType.toLowerCase())}</span>
|
||||||
|
{selectedCharacter.damageType && (
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
src={`https://api.hakush.in/hsr/UI/element/${selectedCharacter.damageType.toLowerCase()}.webp`}
|
||||||
|
className="w-6 h-6"
|
||||||
|
alt={selectedCharacter.damageType.toLowerCase()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center items-center">
|
||||||
|
{selectedCharacter && (
|
||||||
|
<img
|
||||||
|
src={`https://api.hakush.in/hsr/UI/avatarshopicon/${selectedCharacter.id}.webp`}
|
||||||
|
alt={getNameChar(locale, selectedCharacter)}
|
||||||
|
className="h-32 w-32 object-cover rounded-full border-2 border-purple-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md">
|
||||||
|
<p className="mt-2 font-bold text-lg text-cyan-500">{transI18n("totalTurn")}: <span className="text-base-content">{totalTurn.toFixed(2)}</span></p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md">
|
||||||
|
<h4 className="text-lg font-semibold mb-2 text-purple-500">{transI18n("totalDamage")}: <span className="text-base-content">{totalDamage.toFixed(2)}</span></h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md max-h-11/12">
|
||||||
|
<h4 className="text-lg font-semibold mb-4 text-purple-500">{transI18n("skillDamageBreakdown")}</h4>
|
||||||
|
<SkillBarChart avatarId={Number(selectedCharacter.id) ?? 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md max-h-11/12">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h4 className="text-lg font-semibold mb-4 text-cyan-500">{transI18n("damageOverTime")}</h4>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[0, 1].map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setModeLine(m as 0 | 1)}
|
||||||
|
className={`btn btn-sm ${modeLine === m ? "btn-accent" : "btn-ghost"}`}
|
||||||
|
>
|
||||||
|
{transI18n("style")} {m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DamageLineForOne avatarId={Number(selectedCharacter.id) ?? 0} mode={modeLine} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md max-h-11/12">
|
||||||
|
<h4 className="text-lg font-semibold mb-4 text-cyan-500">{transI18n("skillUsageDistribution")}</h4>
|
||||||
|
<SkillPieChart avatarId={Number(selectedCharacter.id) ?? 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-base-200 rounded-lg p-4 shadow-md max-h-11/12">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h4 className="text-lg font-semibold text-purple-500">{transI18n("damagerPerCycle")}</h4>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[0, 1, 2].map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setModeBar(m as 0 | 1 | 2)}
|
||||||
|
className={`btn btn-sm ${modeBar === m ? "btn-accent" : "btn-ghost"}`}
|
||||||
|
>
|
||||||
|
{transI18n("style")} {m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DamagePerCycleForOne avatarId={Number(selectedCharacter.id) ?? 0} mode={modeBar} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/components/themeController/clientThemeWrapper.tsx
Normal file
8
src/components/themeController/clientThemeWrapper.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
import { PropsWithChildren, useContext } from "react";
|
||||||
|
import { ThemeContext } from "./themeContext";
|
||||||
|
|
||||||
|
export function ClientThemeWrapper({ children }: PropsWithChildren) {
|
||||||
|
const { theme } = useContext(ThemeContext);
|
||||||
|
return <div data-theme={theme} className="h-full">{children}</div>;
|
||||||
|
}
|
||||||
2
src/components/themeController/index.ts
Normal file
2
src/components/themeController/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./clientThemeWrapper"
|
||||||
|
export * from "./themeContext"
|
||||||
41
src/components/themeController/themeContext.tsx
Normal file
41
src/components/themeController/themeContext.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
import { createContext, PropsWithChildren, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme?: string;
|
||||||
|
changeTheme?: (nextTheme: string | null) => void;
|
||||||
|
}
|
||||||
|
export const ThemeContext = createContext<ThemeContextType>({});
|
||||||
|
|
||||||
|
export const ThemeProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
const [theme, setTheme] = useState<string>("light");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const storedTheme = localStorage.getItem("theme");
|
||||||
|
if (storedTheme) setTheme(storedTheme);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const changeTheme = (nextTheme: string | null) => {
|
||||||
|
console.log(nextTheme)
|
||||||
|
if (nextTheme) {
|
||||||
|
setTheme(nextTheme);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("theme", nextTheme);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTheme((prev) => (prev === "light" ? "night" : "light"));
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("theme", theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, changeTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
15
src/helper/exportDataBattle.ts
Normal file
15
src/helper/exportDataBattle.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { BattleDataStateJson } from "@/types/mics";
|
||||||
|
|
||||||
|
export const exportBattleData = (
|
||||||
|
data: BattleDataStateJson,
|
||||||
|
filename = 'battle_data.json'
|
||||||
|
) => {
|
||||||
|
const dataStr = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([dataStr], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
19
src/helper/getNameChar.ts
Normal file
19
src/helper/getNameChar.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { AvatarType } from "@/types";
|
||||||
|
|
||||||
|
|
||||||
|
export function getNameChar(locale: string, data: AvatarType | undefined): string {
|
||||||
|
if (!data) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
let text = data.lang.get(locale) ?? "";
|
||||||
|
if (!text) {
|
||||||
|
text = data.lang.get("en") ?? "";
|
||||||
|
}
|
||||||
|
if (Number(data.id) % 2 === 0 && Number(data.id) > 8000) {
|
||||||
|
text = `Female ${data.damageType} MC`
|
||||||
|
} else if (Number(data.id) > 8000) {
|
||||||
|
text = `Male ${data.damageType} MC`
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
21
src/helper/importDataBattle.ts
Normal file
21
src/helper/importDataBattle.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { BattleDataStateJson } from "@/types/mics";
|
||||||
|
|
||||||
|
export const importBattleData = (file: File, loadBattleDataFromJSON: (data: BattleDataStateJson) => void) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
const result = reader.result as string;
|
||||||
|
const parsed = JSON.parse(result) as BattleDataStateJson;
|
||||||
|
loadBattleDataFromJSON(parsed);
|
||||||
|
resolve();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
3
src/helper/index.ts
Normal file
3
src/helper/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./getNameChar"
|
||||||
|
export * from "./exportDataBattle"
|
||||||
|
export * from "./importDataBattle"
|
||||||
28
src/hooks/useCalcAvatarData.ts
Normal file
28
src/hooks/useCalcAvatarData.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import useBattleDataStore from "@/stores/battleDataStore";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function useCalcTotalDmgAvatar(avatarId: number) {
|
||||||
|
const { turnHistory } = useBattleDataStore.getState();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return turnHistory
|
||||||
|
.filter(t => t.avatarId === avatarId)
|
||||||
|
.reduce((sum, turn) => sum + turn.totalDamage, 0);
|
||||||
|
}, [avatarId, turnHistory]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function useCalcTotalTurnAvatar(avatarId: number) {
|
||||||
|
const { turnHistory } = useBattleDataStore.getState();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const uniqueActionValues = new Set<number>();
|
||||||
|
turnHistory.forEach(turn => {
|
||||||
|
if (turn.avatarId === avatarId) {
|
||||||
|
uniqueActionValues.add(turn.actionValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return uniqueActionValues.size;
|
||||||
|
}, [avatarId, turnHistory]);
|
||||||
|
}
|
||||||
4
src/hooks/useChangeTheme.ts
Normal file
4
src/hooks/useChangeTheme.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { ThemeContext } from "@/components/themeController";
|
||||||
|
|
||||||
|
export const useChangeTheme = () => useContext(ThemeContext);
|
||||||
31
src/hooks/useDamageLineForOne.ts
Normal file
31
src/hooks/useDamageLineForOne.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import useBattleDataStore from "@/stores/battleDataStore";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function useDamageLineForOne(avatarId: number, mode: 0 | 1 = 0) {
|
||||||
|
const { turnHistory } = useBattleDataStore.getState();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const map = new Map<number, number>();
|
||||||
|
|
||||||
|
for (const turn of turnHistory) {
|
||||||
|
if (turn.avatarId !== avatarId) continue;
|
||||||
|
|
||||||
|
const prev = map.get(turn.actionValue) || 0;
|
||||||
|
map.set(turn.actionValue, prev + turn.totalDamage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = Array.from(map.entries())
|
||||||
|
.map(([x, y]) => ({ x, y }))
|
||||||
|
.sort((a, b) => a.x - b.x);
|
||||||
|
|
||||||
|
if (mode === 1) {
|
||||||
|
let cumulative = 0;
|
||||||
|
return points.map(p => {
|
||||||
|
cumulative += p.y;
|
||||||
|
return { x: p.x, y: Number(cumulative.toFixed(2)) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return points.map(p => ({ x: p.x, y: Number(p.y.toFixed(2)) }));
|
||||||
|
}, [avatarId, turnHistory, mode]);
|
||||||
|
}
|
||||||
43
src/hooks/useDamageLinesForAll.ts
Normal file
43
src/hooks/useDamageLinesForAll.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import useBattleDataStore from "@/stores/battleDataStore";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function useDamageLinesForAll(mode: 0 | 1 = 0) {
|
||||||
|
const { turnHistory } = useBattleDataStore.getState();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const avatarMap = new Map<number, Map<number, number>>();
|
||||||
|
|
||||||
|
for (const turn of turnHistory) {
|
||||||
|
if (!avatarMap.has(turn.avatarId)) {
|
||||||
|
avatarMap.set(turn.avatarId, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
const charMap = avatarMap.get(turn.avatarId)!;
|
||||||
|
const prev = charMap.get(turn.actionValue) || 0;
|
||||||
|
charMap.set(turn.actionValue, prev + turn.totalDamage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<number, { x: number; y: number }[]> = {};
|
||||||
|
|
||||||
|
for (const [avatarId, damageMap] of avatarMap.entries()) {
|
||||||
|
const points = Array.from(damageMap.entries())
|
||||||
|
.map(([x, y]) => ({ x, y }))
|
||||||
|
.sort((a, b) => a.x - b.x);
|
||||||
|
|
||||||
|
if (mode === 1) {
|
||||||
|
let cumulative = 0;
|
||||||
|
result[avatarId] = points.map(p => {
|
||||||
|
cumulative += p.y;
|
||||||
|
return { x: p.x, y: Number(cumulative.toFixed(2)) };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result[avatarId] = points.map(p => ({
|
||||||
|
x: p.x,
|
||||||
|
y: Number(p.y.toFixed(2)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [turnHistory, mode]);
|
||||||
|
}
|
||||||
79
src/hooks/useDamagePerCycle.ts
Normal file
79
src/hooks/useDamagePerCycle.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import useBattleDataStore from "@/stores/battleDataStore";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
type Mode = 0 | 1 | 2;
|
||||||
|
|
||||||
|
function getChunkIndex(av: number, mode: Mode): number {
|
||||||
|
let sum = 0;
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
if (mode === 0) {
|
||||||
|
while (sum <= av) {
|
||||||
|
sum += 100;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
} else if (mode === 1) {
|
||||||
|
if (av < 150) return 0;
|
||||||
|
sum = 150;
|
||||||
|
idx = 1;
|
||||||
|
while (sum <= av) {
|
||||||
|
sum += 100;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (av < 150) return 0;
|
||||||
|
if (av < 300) return 1;
|
||||||
|
sum = 300;
|
||||||
|
idx = 2;
|
||||||
|
while (sum <= av) {
|
||||||
|
sum += 100;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return idx - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDamagePerCycleForOne(avatarId: number, mode: Mode) {
|
||||||
|
const { turnHistory } = useBattleDataStore.getState();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const damageMap = new Map<number, number>();
|
||||||
|
|
||||||
|
turnHistory
|
||||||
|
.filter(t => t.avatarId === avatarId)
|
||||||
|
.forEach(t => {
|
||||||
|
const idx = getChunkIndex(t.actionValue, mode);
|
||||||
|
damageMap.set(idx, (damageMap.get(idx) || 0) + t.totalDamage);
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxIndex = Math.max(...Array.from(damageMap.keys()), 0);
|
||||||
|
|
||||||
|
return Array.from({ length: maxIndex + 1 }).map((_, i) => ({
|
||||||
|
x: `${i + 1}`,
|
||||||
|
y: damageMap.get(i) || 0,
|
||||||
|
}));
|
||||||
|
}, [avatarId, mode, turnHistory]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function useDamagePerCycleForAll(mode: Mode) {
|
||||||
|
const { turnHistory } = useBattleDataStore.getState();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const damageMap = new Map<number, number>();
|
||||||
|
|
||||||
|
turnHistory.forEach(t => {
|
||||||
|
const idx = getChunkIndex(t.actionValue, mode);
|
||||||
|
damageMap.set(idx, (damageMap.get(idx) || 0) + t.totalDamage);
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxIndex = Math.max(...Array.from(damageMap.keys()), 0);
|
||||||
|
|
||||||
|
return Array.from({ length: maxIndex + 1 }).map((_, i) => ({
|
||||||
|
x: `${i + 1}`,
|
||||||
|
y: damageMap.get(i) || 0,
|
||||||
|
}));
|
||||||
|
}, [mode, turnHistory]);
|
||||||
|
}
|
||||||
21
src/hooks/useDamagePercentPerAvatar.ts
Normal file
21
src/hooks/useDamagePercentPerAvatar.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import useBattleDataStore from "@/stores/battleDataStore";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function useDamagePercentPerAvatar() {
|
||||||
|
const { turnHistory } = useBattleDataStore.getState();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const dmgByAvatar = new Map<number, number>();
|
||||||
|
|
||||||
|
turnHistory.forEach(t => {
|
||||||
|
dmgByAvatar.set(t.avatarId, (dmgByAvatar.get(t.avatarId) || 0) + t.totalDamage);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalDmg = Array.from(dmgByAvatar.values()).reduce((sum, v) => sum + v, 0);
|
||||||
|
|
||||||
|
return Array.from(dmgByAvatar.entries()).map(([avatarId, dmg]) => ({
|
||||||
|
avatarId,
|
||||||
|
percent: totalDmg > 0 ? (dmg / totalDmg) * 100 : 0,
|
||||||
|
}));
|
||||||
|
}, [turnHistory]);
|
||||||
|
}
|
||||||
25
src/hooks/useSkillDamageStats.ts
Normal file
25
src/hooks/useSkillDamageStats.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import useBattleDataStore from "@/stores/battleDataStore";
|
||||||
|
|
||||||
|
export function useSkillDamageByAvatar(avatarId: number) {
|
||||||
|
const { turnHistory } = useBattleDataStore.getState();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const skillTypes = ['technique', 'talent', 'basic', 'skill', 'ultimate', 'servant'];
|
||||||
|
const dmgMap = new Map<string, number>();
|
||||||
|
skillTypes.forEach((type) => dmgMap.set(type, 0));
|
||||||
|
|
||||||
|
for (const turn of turnHistory) {
|
||||||
|
const type = turn.skillType.toLowerCase();
|
||||||
|
if (turn.avatarId === avatarId && dmgMap.has(type)) {
|
||||||
|
dmgMap.set(type, dmgMap.get(type)! + turn.totalDamage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = Array.from(dmgMap.keys());
|
||||||
|
const damageValues = Array.from(dmgMap.values());
|
||||||
|
|
||||||
|
return { labels, damageValues };
|
||||||
|
}, [avatarId, turnHistory]);
|
||||||
|
}
|
||||||
65
src/lib/api.ts
Normal file
65
src/lib/api.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import useSocketStore from "@/stores/socketSettingStore";
|
||||||
|
import { AvatarHakushiType, AvatarType } from "@/types/avatar";
|
||||||
|
|
||||||
|
|
||||||
|
export async function checkConnectTcpApi(): Promise<boolean> {
|
||||||
|
const { host, port, connectionType } = useSocketStore.getState()
|
||||||
|
let url = `${host}:${port}/check-tcp`
|
||||||
|
if (connectionType === "FireflyPSLocal") {
|
||||||
|
url = "http://localhost:21000/check-tcp"
|
||||||
|
}
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
return data.status
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCharacterListApi(): Promise<AvatarType[]> {
|
||||||
|
const res = await fetch('/api/hakushin', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.log(`Error ${res.status}: ${res.statusText}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Map<string, AvatarHakushiType> = new Map(Object.entries(await res.json()));
|
||||||
|
|
||||||
|
|
||||||
|
return Array.from(data.entries()).map(([id, it]) => convertAvatar(id, it));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function convertAvatar(id: string, item: AvatarHakushiType): AvatarType {
|
||||||
|
const lang = new Map<string, string>([
|
||||||
|
['en', item.en],
|
||||||
|
['kr', item.kr],
|
||||||
|
['cn', item.cn],
|
||||||
|
['jp', item.jp]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result: AvatarType = {
|
||||||
|
release: item.release,
|
||||||
|
icon: item.icon,
|
||||||
|
rank: item.rank,
|
||||||
|
baseType: item.baseType,
|
||||||
|
damageType: item.damageType,
|
||||||
|
desc: item.desc,
|
||||||
|
lang: lang,
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
125
src/lib/socket.ts
Normal file
125
src/lib/socket.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { io, Socket } from "socket.io-client";
|
||||||
|
import useSocketStore from "@/stores/socketSettingStore";
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import useBattleDataStore from "@/stores/battleDataStore";
|
||||||
|
|
||||||
|
let socket: Socket | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
const onBattleBegin = () => {
|
||||||
|
notify("Battle Started!", "info")
|
||||||
|
}
|
||||||
|
|
||||||
|
const notify = (msg: string, type: 'info' | 'success' | 'error' = 'info') => {
|
||||||
|
if (type === 'success') toast.success(msg);
|
||||||
|
else if (type === 'error') toast.error(msg);
|
||||||
|
else toast.info(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const connectSocket = (): Socket => {
|
||||||
|
const { host, port, connectionType, setStatus } = useSocketStore.getState();
|
||||||
|
const {
|
||||||
|
onSetBattleLineupService,
|
||||||
|
onTurnEndService,
|
||||||
|
onUseSkillService,
|
||||||
|
onKillService,
|
||||||
|
onDamageService,
|
||||||
|
onBattleEndService,
|
||||||
|
onTurnBeginService
|
||||||
|
} = useBattleDataStore.getState();
|
||||||
|
|
||||||
|
let url = `${host}:${port}`;
|
||||||
|
if (connectionType === "FireflyPSLocal") {
|
||||||
|
url = "http://localhost:21000"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = io(url, {
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
timeout: 10000,
|
||||||
|
autoConnect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
console.log("Socket connected");
|
||||||
|
setStatus(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
console.log("Socket disconnected");
|
||||||
|
setStatus(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("connect_error", (err) => {
|
||||||
|
console.error("Connection error:", err);
|
||||||
|
setStatus(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("connect_timeout", () => {
|
||||||
|
console.warn("Connection timeout");
|
||||||
|
setStatus(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("reconnect_failed", () => {
|
||||||
|
console.error("Reconnect failed");
|
||||||
|
setStatus(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onConnect = () => {
|
||||||
|
setStatus(true);
|
||||||
|
notify(`Kết nối thành công với Socket ID: ${socket?.id}`, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSocketConnected()) onConnect();
|
||||||
|
|
||||||
|
socket.on("SetBattleLineup", (json) => onSetBattleLineupService(JSON.parse(json)));
|
||||||
|
socket.on("TurnEnd", (json) => onTurnEndService(JSON.parse(json)));
|
||||||
|
socket.on("OnUseSkill", (json) => onUseSkillService(JSON.parse(json)));
|
||||||
|
socket.on("OnKill", (json) => onKillService(JSON.parse(json)));
|
||||||
|
socket.on("OnDamage", (json) => onDamageService(JSON.parse(json)));
|
||||||
|
socket.on('BattleBegin', () => onBattleBegin());
|
||||||
|
socket.on('TurnBegin', (json) => onTurnBeginService(JSON.parse(json)));
|
||||||
|
socket.on('BattleEnd', (json) => onBattleEndService(JSON.parse(json)));
|
||||||
|
|
||||||
|
socket.on("Error", (msg: string) => {
|
||||||
|
console.error("Server Error:", msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disconnectSocket = (): void => {
|
||||||
|
const {
|
||||||
|
onSetBattleLineupService,
|
||||||
|
onTurnEndService,
|
||||||
|
onUseSkillService,
|
||||||
|
onKillService,
|
||||||
|
onDamageService,
|
||||||
|
onBattleEndService,
|
||||||
|
onTurnBeginService
|
||||||
|
} = useBattleDataStore.getState();
|
||||||
|
if (socket) {
|
||||||
|
socket.off("SetBattleLineup", (json) => onSetBattleLineupService(JSON.parse(json)));
|
||||||
|
socket.off("TurnEnd", (json) => onTurnEndService(JSON.parse(json)));
|
||||||
|
socket.off("OnUseSkill", (json) => onUseSkillService(JSON.parse(json)));
|
||||||
|
socket.off("OnKill", (json) => onKillService(JSON.parse(json)));
|
||||||
|
socket.off("OnDamage", (json) => onDamageService(JSON.parse(json)));
|
||||||
|
socket.off('BattleBegin', () => onBattleBegin());
|
||||||
|
socket.off('TurnBegin', (json) => onTurnBeginService(JSON.parse(json)));
|
||||||
|
socket.off('BattleEnd', (json) => onBattleEndService(JSON.parse(json)));
|
||||||
|
socket.offAny();
|
||||||
|
socket.disconnect();
|
||||||
|
useSocketStore.getState().setStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSocketConnected = (): boolean => {
|
||||||
|
return socket?.connected || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSocket = (): Socket | null => {
|
||||||
|
return socket;
|
||||||
|
};
|
||||||
16
src/stores/avatarDataStore.ts
Normal file
16
src/stores/avatarDataStore.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { AvatarType } from '@/types';
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
|
||||||
|
interface AvatarDataState {
|
||||||
|
listAvatar: AvatarType[];
|
||||||
|
setListAvatar: (list: AvatarType[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useAvatarDataStore = create<AvatarDataState>((set) => ({
|
||||||
|
listAvatar: [],
|
||||||
|
setListAvatar: (list: AvatarType[]) => set({ listAvatar: list }),
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useAvatarDataStore;
|
||||||
129
src/stores/battleDataStore.ts
Normal file
129
src/stores/battleDataStore.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { AttackResultType, AvatarSkillType, BattleEndType, KillType, LineUpType, TurnBeginType, TurnEndType } from '@/types';
|
||||||
|
import { AvatarBattleInfo, BattleDataStateJson, TurnBattleInfo } from '@/types/mics';
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
|
||||||
|
interface BattleDataState {
|
||||||
|
lineup: AvatarBattleInfo[];
|
||||||
|
turnHistory: TurnBattleInfo[]
|
||||||
|
totalAV: number;
|
||||||
|
totalDamage: number;
|
||||||
|
damagePerAV: number;
|
||||||
|
|
||||||
|
onSetBattleLineupService: (data: LineUpType) => void;
|
||||||
|
onTurnEndService: (data: TurnEndType) => void;
|
||||||
|
onBattleEndService: (data: BattleEndType) => void;
|
||||||
|
onUseSkillService: (data: AvatarSkillType) => void;
|
||||||
|
onKillService: (data: KillType) => void
|
||||||
|
onDamageService: (data: AttackResultType) => void;
|
||||||
|
onBattleStartService: () => void;
|
||||||
|
onTurnBeginService: (data: TurnBeginType) => void;
|
||||||
|
loadBattleDataFromJSON: (data: BattleDataStateJson) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useBattleDataStore = create<BattleDataState>((set, get) => ({
|
||||||
|
lineup: [],
|
||||||
|
turnHistory: [],
|
||||||
|
totalAV: 0,
|
||||||
|
totalDamage: 0,
|
||||||
|
damagePerAV: 0,
|
||||||
|
|
||||||
|
loadBattleDataFromJSON: (data: BattleDataStateJson) => {
|
||||||
|
set({
|
||||||
|
lineup: data.lineup,
|
||||||
|
turnHistory: data.turnHistory,
|
||||||
|
totalAV: data.totalAV,
|
||||||
|
totalDamage: data.totalDamage,
|
||||||
|
damagePerAV: data.damagePerAV,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onBattleStartService: () => {
|
||||||
|
set({
|
||||||
|
lineup: [],
|
||||||
|
turnHistory: [],
|
||||||
|
totalAV: 0,
|
||||||
|
totalDamage: 0,
|
||||||
|
damagePerAV: 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDamageService: (data: AttackResultType) => {
|
||||||
|
const turnHistorys = get().turnHistory
|
||||||
|
const turnIdx = turnHistorys.findLastIndex(it => it.avatarId === data.attacker.id)
|
||||||
|
if (turnIdx === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newTh = [...turnHistorys]
|
||||||
|
newTh[turnIdx].damageDetail.push(data.damage)
|
||||||
|
newTh[turnIdx].totalDamage += data.damage
|
||||||
|
set({
|
||||||
|
turnHistory: newTh,
|
||||||
|
totalDamage: get().totalDamage + data.damage,
|
||||||
|
damagePerAV: (get().totalDamage + data.damage) / (get().totalAV === 0 ? 1 : get().totalAV)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onKillService: (data: KillType) => {
|
||||||
|
const lineups = get().lineup
|
||||||
|
const avatarIdx = lineups.findIndex(it => it.avatarId === data.attacker.id)
|
||||||
|
if (avatarIdx === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newLn = [...lineups]
|
||||||
|
newLn[avatarIdx].isDie = true
|
||||||
|
set({
|
||||||
|
lineup: newLn
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSetBattleLineupService: (data: LineUpType) => {
|
||||||
|
const lineups: AvatarBattleInfo[] = []
|
||||||
|
for (const avatar of data.avatars) {
|
||||||
|
lineups.push({ avatarId: avatar.id, isDie: false } as AvatarBattleInfo)
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
lineup: lineups,
|
||||||
|
turnHistory: [],
|
||||||
|
totalAV: 0,
|
||||||
|
totalDamage: 0,
|
||||||
|
damagePerAV: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onTurnBeginService: (data: TurnBeginType) => {
|
||||||
|
set((state) => ({
|
||||||
|
totalAV: data.action_value,
|
||||||
|
damagePerAV: state.totalDamage / (data.action_value === 0 ? 1 : data.action_value)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
onTurnEndService: (data: TurnEndType) => {
|
||||||
|
set((state) => ({
|
||||||
|
totalDamage: state.totalDamage === data.total_damage ? data.total_damage : state.totalDamage,
|
||||||
|
totalAV: data.action_value,
|
||||||
|
damagePerAV: (state.totalDamage === data.total_damage ? data.total_damage : state.totalDamage) / (data.action_value === 0 ? 1 : data.action_value)
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onUseSkillService: (data: AvatarSkillType) => {
|
||||||
|
set((state) => ({
|
||||||
|
turnHistory: [...state.turnHistory, {
|
||||||
|
avatarId: data.avatar.id,
|
||||||
|
damageDetail: [],
|
||||||
|
totalDamage: 0,
|
||||||
|
actionValue: state.totalAV,
|
||||||
|
skillType: data.skill.type,
|
||||||
|
skillName: data.skill.name
|
||||||
|
} as TurnBattleInfo]
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
onBattleEndService: (data: BattleEndType) => {
|
||||||
|
const lineups: AvatarBattleInfo[] = []
|
||||||
|
for (const avatar of data.avatars) {
|
||||||
|
lineups.push({ avatarId: avatar.id, isDie: false } as AvatarBattleInfo)
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
lineup: lineups,
|
||||||
|
totalDamage: data.total_damage,
|
||||||
|
totalAV: data.action_value,
|
||||||
|
damagePerAV: data.total_damage / (data.action_value === 0 ? 1 : data.action_value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useBattleDataStore;
|
||||||
15
src/stores/localeStore.ts
Normal file
15
src/stores/localeStore.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
|
||||||
|
interface LocaleState {
|
||||||
|
locale: string;
|
||||||
|
setLocale: (newLocale: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useLocaleStore = create<LocaleState>((set) => ({
|
||||||
|
locale: "en",
|
||||||
|
setLocale: (newLocale: string) => set({ locale: newLocale }),
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useLocaleStore;
|
||||||
33
src/stores/socketSettingStore.ts
Normal file
33
src/stores/socketSettingStore.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
interface SocketState {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
status: boolean;
|
||||||
|
connectionType: string;
|
||||||
|
setHost: (host: string) => void;
|
||||||
|
setConnectionType: (host: string) => void;
|
||||||
|
setPort: (port: number) => void;
|
||||||
|
setStatus: (status: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSocketStore = create<SocketState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
host: "http://localhost",
|
||||||
|
port: 3443,
|
||||||
|
status: false,
|
||||||
|
connectionType: "FireflyPSLocal",
|
||||||
|
setHost: (host: string) => set({ host }),
|
||||||
|
setConnectionType: (connectionType: string) => set({ connectionType }),
|
||||||
|
setPort: (port: number) => set({ port }),
|
||||||
|
setStatus: (status: boolean) => set({ status }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'socket-storage',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useSocketStore;
|
||||||
6
src/types/attack.ts
Normal file
6
src/types/attack.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { AvatarInfo } from "./lineup";
|
||||||
|
|
||||||
|
export interface AttackResultType {
|
||||||
|
attacker: AvatarInfo;
|
||||||
|
damage: number;
|
||||||
|
}
|
||||||
23
src/types/avatar.ts
Normal file
23
src/types/avatar.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface AvatarHakushiType {
|
||||||
|
release: number;
|
||||||
|
icon: string;
|
||||||
|
rank: string;
|
||||||
|
baseType: string;
|
||||||
|
damageType: string;
|
||||||
|
en: string;
|
||||||
|
desc: string;
|
||||||
|
kr: string;
|
||||||
|
cn: string;
|
||||||
|
jp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarType {
|
||||||
|
id: string;
|
||||||
|
release: number;
|
||||||
|
icon: string;
|
||||||
|
rank: string;
|
||||||
|
baseType: string;
|
||||||
|
damageType: string;
|
||||||
|
desc: string;
|
||||||
|
lang: Map<string, string>;
|
||||||
|
}
|
||||||
14
src/types/battle.ts
Normal file
14
src/types/battle.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { AvatarInfo } from "./lineup";
|
||||||
|
import { TurnInfo } from "./turn";
|
||||||
|
|
||||||
|
export interface BattleEndType {
|
||||||
|
avatars: AvatarInfo[];
|
||||||
|
turn_history: TurnInfo[];
|
||||||
|
turn_count: number;
|
||||||
|
total_damage: number;
|
||||||
|
action_value: number;
|
||||||
|
}
|
||||||
|
export interface KillType {
|
||||||
|
attacker: AvatarInfo;
|
||||||
|
}
|
||||||
|
|
||||||
6
src/types/index.ts
Normal file
6
src/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from "./attack"
|
||||||
|
export * from "./avatar"
|
||||||
|
export * from "./battle"
|
||||||
|
export * from "./lineup"
|
||||||
|
export * from "./skill"
|
||||||
|
export * from "./turn"
|
||||||
8
src/types/lineup.ts
Normal file
8
src/types/lineup.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface AvatarInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineUpType {
|
||||||
|
avatars: AvatarInfo[];
|
||||||
|
}
|
||||||
21
src/types/mics.ts
Normal file
21
src/types/mics.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface AvatarBattleInfo {
|
||||||
|
avatarId: number;
|
||||||
|
isDie: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnBattleInfo {
|
||||||
|
avatarId: number;
|
||||||
|
damageDetail: number[];
|
||||||
|
totalDamage: number;
|
||||||
|
actionValue: number;
|
||||||
|
skillType: string;
|
||||||
|
skillName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleDataStateJson {
|
||||||
|
lineup: AvatarBattleInfo[];
|
||||||
|
turnHistory: TurnBattleInfo[]
|
||||||
|
totalAV: number;
|
||||||
|
totalDamage: number;
|
||||||
|
damagePerAV: number;
|
||||||
|
}
|
||||||
11
src/types/skill.ts
Normal file
11
src/types/skill.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { AvatarInfo } from "./lineup";
|
||||||
|
|
||||||
|
export interface SkillInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarSkillType {
|
||||||
|
avatar: AvatarInfo;
|
||||||
|
skill: SkillInfo;
|
||||||
|
}
|
||||||
19
src/types/turn.ts
Normal file
19
src/types/turn.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { AvatarInfo } from "./lineup";
|
||||||
|
|
||||||
|
|
||||||
|
export interface TurnInfo {
|
||||||
|
avatars_turn_damage: number[];
|
||||||
|
total_damage: number;
|
||||||
|
action_value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnBeginType {
|
||||||
|
action_value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnEndType {
|
||||||
|
avatars: AvatarInfo[];
|
||||||
|
avatars_damage: number[];
|
||||||
|
total_damage: number;
|
||||||
|
action_value: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user