fix: good friend

This commit is contained in:
王性驊 2025-11-27 17:31:56 +08:00
parent 63ee9b71ef
commit c779fd9a0e
6 changed files with 385 additions and 104 deletions

View File

@ -1,12 +1,5 @@
<template>
<div class="flex flex-col h-full bg-black text-[#99e550] relative">
<!-- Header -->
<div class="flex items-center gap-2 text-xl font-bold p-2 border-b-2 border-[#99e550]">
<Map class="text-[#e0d8f0]" />
<span class="tracking-widest">選擇冒險區域 (SELECT ZONE)</span>
<button @click="$emit('close')" class="ml-auto text-white hover:text-red-500"><X /></button>
</div>
<!-- Content -->
<div class="flex-grow overflow-y-auto p-4 custom-scrollbar flex flex-col gap-4">
<div
@ -21,7 +14,7 @@
</div>
<!-- Description -->
<p class="text-xs text-white mb-4 text-center">
<p class="text-xs text-white mb-4">
{{ loc.description }}
</p>
@ -44,37 +37,38 @@
</div>
</div>
<!-- Action Button -->
<!-- Action Buttons -->
<div class="flex gap-2 mt-auto">
<!-- Battle Button: Requires Stats & Costs -->
<button
@click="!isLocked(loc) && canAfford(loc) && $emit('selectLocation', loc)"
@click="!isLocked(loc) && canAfford(loc) && $emit('selectLocation', { location: loc, mode: 'battle' })"
:disabled="!canAfford(loc) || isLocked(loc)"
class="w-full py-2 text-lg tracking-[0.2em] border"
class="flex-1 py-2 text-sm tracking-[0.1em] border transition-colors"
:class="(!canAfford(loc) || isLocked(loc))
? 'border-gray-600 text-gray-500 cursor-not-allowed'
: 'border-[#d95763] text-[#d95763] hover:bg-[#d95763] hover:text-black'"
>
{{ isLocked(loc) ? "能力不足" : !canAfford(loc) ? "資源不足" : "出發 !" }}
<Swords :size="16" class="inline mr-1" /> 戰鬥
</button>
<!-- View Button: No Restrictions -->
<button
@click="$emit('selectLocation', { location: loc, mode: 'view' })"
class="flex-1 py-2 text-sm tracking-[0.1em] border transition-colors border-[#2ce8f4] text-[#2ce8f4] hover:bg-[#2ce8f4] hover:text-black"
>
<Eye :size="16" class="inline mr-1" /> 參觀
</button>
</div>
<!-- Side Decoration Bar -->
<div class="absolute top-2 bottom-2 right-2 w-2" :class="isLocked(loc) ? 'bg-gray-600' : 'bg-[#99e550]'"></div>
</div>
</div>
<!-- Footer / Close Button -->
<div class="p-4 border-t border-[#99e550]">
<button
@click="$emit('close')"
class="border border-[#99e550] text-[#99e550] px-4 py-2 hover:bg-[#99e550] hover:text-black"
>
關閉
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { Map, Drumstick, Coins, X, Swords } from 'lucide-vue-next';
import { Map, Drumstick, Coins, X, Swords, Eye } from 'lucide-vue-next';
import PixelFrame from './PixelFrame.vue';
import PixelButton from './PixelButton.vue';

View File

@ -21,10 +21,10 @@
<div v-else class="h-full w-full relative overflow-hidden bg-[#0f0816]">
<!-- Background Layer with Darker Filter -->
<div
class="absolute inset-0 bg-cover bg-center opacity-50"
class="absolute inset-0 bg-cover bg-center transition-all duration-1000"
:style="{
backgroundImage: `url('https://picsum.photos/seed/dungeon/800/400')`,
filter: 'contrast(1.2) brightness(0.5) sepia(0.5) hue-rotate(260deg) saturate(1.5)'
backgroundImage: `url('${backgroundImage || 'https://picsum.photos/seed/dungeon/800/400'}')`,
filter: 'contrast(1.1) brightness(0.6) sepia(0.3)'
}"
/>
@ -58,33 +58,24 @@
</div>
</PixelFrame>
</div>
<!-- Main Pet Avatar (Center, Idle) -->
<div class="absolute bottom-16 left-1/2 transform -translate-x-1/2 z-10 scale-[3]">
<PixelAvatar
skinColor="#ffdbac"
hairColor="#e0d8f0"
outfitColor="#9fd75b"
:deityId="currentDeityId"
weapon="staff"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import PixelAvatar from './PixelAvatar.vue';
// PixelAvatar removed as requested
interface Props {
currentDeityId?: string;
isFighting?: boolean;
battleLogs?: string[];
backgroundImage?: string;
}
const props = withDefaults(defineProps<Props>(), {
isFighting: false,
battleLogs: () => []
battleLogs: () => [],
backgroundImage: ''
});
const logEndRef = ref<HTMLDivElement | null>(null);

View File

@ -28,6 +28,7 @@
:currentDeityId="currentDeity"
:isFighting="isFighting"
:battleLogs="battleLogs"
:backgroundImage="currentLocation?.backgroundImage || ''"
/>
</div>
@ -129,7 +130,7 @@
title="冒險"
>
<AdventureOverlay
:locations="ADVENTURE_LOCATIONS"
:locations="LOCATIONS"
:playerStats="playerStats"
@selectLocation="handleStartAdventure"
@close="showAdventureSelect = false"
@ -143,7 +144,18 @@
<PartyPopper :size="48" class="text-[#99e550] animate-bounce" />
<h2 class="text-2xl text-[#99e550] font-bold tracking-widest">冒險完成 !</h2>
<div class="w-full border-t border-gray-700 my-2"></div>
<p class="text-gray-400 text-sm">這次沒有獲得任何獎勵...</p>
<!-- Rewards -->
<div v-if="battleResult && battleResult.rewards" class="w-full text-left text-sm text-gray-300">
<p v-if="battleResult.rewards.exp && battleResult.rewards.exp > 0"> 獲得 {{ battleResult.rewards.exp }} 經驗值</p>
<p v-if="battleResult.rewards.items && battleResult.rewards.items.length > 0">🎁 獲得道具</p>
<ul v-if="battleResult.rewards.items && battleResult.rewards.items.length > 0" class="list-disc list-inside ml-4">
<li v-for="(item, idx) in battleResult.rewards.items" :key="idx">{{ item.id }} x{{ item.count }}</li>
</ul>
</div>
<!-- Logs -->
<div v-if="battleResult && battleResult.logs && battleResult.logs.length > 0" class="w-full max-h-48 overflow-y-auto custom-scrollbar text-xs text-gray-200">
<p class="font-mono" v-for="(log, idx) in battleResult.logs" :key="idx">{{ idx + 1 }}. {{ log }}</p>
</div>
<button
@click="handleCloseBattleResult"
class="mt-6 border border-[#99e550] text-[#99e550] px-8 py-2 hover:bg-[#99e550] hover:text-black tracking-widest"
@ -188,6 +200,7 @@ import { DEITIES } from '../../data/deities.js';
import { ITEMS, ITEM_RARITY, EQUIPMENT_SLOTS } from '../../data/items.js';
import { ADVENTURES } from '../../data/adventures.js';
import { ACHIEVEMENTS } from '../../data/achievements.js';
import { LOCATIONS } from '../../data/locations.js';
// Type definitions (minimal, based on actual data structures)
type EntityStats = any;
@ -313,11 +326,14 @@ const showGodSystem = ref(false);
const showShop = ref(false);
const showAdventureSelect = ref(false);
const showBattleResult = ref(false);
const battleResult = ref<{logs:string[], rewards:any}|null>(null);
// Battle State
const isFighting = ref(false);
const battleLogs = ref<string[]>([]);
const showNamingOverlay = ref(false);
const currentLocation = ref<any>(null);
const combatInterval = ref<any>(null);
const handleNameSubmit = async (payload: { name: string, species: string }) => {
if (petSystem.value) {
@ -347,6 +363,26 @@ const handleDeletePet = async () => {
}
};
// --- HELPERS (Moved) ---
const ADVENTURE_LOCATIONS = ADVENTURES;
const ACHIEVEMENTS_DATA = ACHIEVEMENTS;
const updateAchievementsState = () => {
if (achievementSystem.value) {
const all = achievementSystem.value.achievements;
achievementsState.value = all.map(achievement => {
const progress = achievementSystem.value!.getAchievementProgress(achievement);
const isUnlocked = achievementSystem.value!.unlockedAchievements.includes(achievement.id);
return {
...achievement,
unlocked: isUnlocked,
currentValue: progress.current,
maxValue: progress.target,
progress: Math.floor(progress.progress || 0)
};
});
}
};
// --- LIFECYCLE ---
const stateSyncInterval = ref<any>(null);
@ -438,43 +474,79 @@ const formatAge = (seconds: number) => {
// --- HANDLERS ---
const handleStartAdventure = (location: AdventureLocation) => {
const handleStartAdventure = (selection: any) => {
const { location, mode } = selection;
showAdventureSelect.value = false;
// Deduct costs
if (petSystem.value) {
petSystem.value.updateState({
hunger: Math.max(0, systemState.value.hunger - location.costHunger),
coins: Math.max(0, systemState.value.coins - location.costGold)
hunger: Math.max(0, systemState.value.hunger - (location.costHunger || 0)),
coins: Math.max(0, systemState.value.coins - (location.costGold || 0))
});
}
isFighting.value = true;
battleLogs.value = [`Entered ${location.name}...`, `Encountered ${location.enemyName}!`];
currentLocation.value = location;
let turn = 1;
const interval = setInterval(() => {
if (turn > 5) {
clearInterval(interval);
battleLogs.value.push("Victory!", "Obtained 10 EXP!");
if (mode === 'battle') {
isFighting.value = true;
startCombatLoop(location);
} else {
isFighting.value = false;
handleViewLocation(location);
}
};
const startCombatLoop = (location: any) => {
// Select random enemy from pool
const enemyId = location.enemyPool[Math.floor(Math.random() * location.enemyPool.length)];
const result = petSystem.value.startCombat(enemyId);
if (result.success) {
battleLogs.value = result.combatState.logs;
// Clear existing interval if any
if (combatInterval.value) clearInterval(combatInterval.value);
// Start Combat Interval
combatInterval.value = setInterval(() => {
const roundResult = petSystem.value.combatRound();
if (roundResult) {
battleLogs.value = [...roundResult.logs]; // Update logs
if (roundResult.isOver) {
clearInterval(combatInterval.value);
combatInterval.value = null;
// Delay before closing or showing result
setTimeout(() => {
isFighting.value = false;
battleResult.value = { logs: roundResult.logs, rewards: roundResult.rewards || {} };
showBattleResult.value = true;
}, 1500);
return;
}, 3000);
}
}
}, 1500); // 1.5s per round
}
};
const isPlayerTurn = turn % 2 !== 0;
if (isPlayerTurn) {
battleLogs.value.push(`You used Attack! Dealt ${Math.floor(Math.random() * 20) + 10} damage.`);
} else {
battleLogs.value.push(`${location.enemyName} attacked! You took ${Math.floor(Math.random() * 10)} damage.`);
const handleViewLocation = (location: any) => {
// Trigger random event
if (location.events && location.events.length > 0) {
const event = location.events[Math.floor(Math.random() * location.events.length)];
console.log("Event:", event);
// For now, maybe just log it or show a simple alert/notification
alert(`[${location.name}] ${event.text}`);
if (event.type === 'item') {
// Add item logic here if needed
}
}
turn++;
}, 1000);
};
const handleCloseBattleResult = () => {
showBattleResult.value = false;
battleLogs.value = [];
battleResult.value = null;
};
const handleFeed = async () => {
@ -828,24 +900,7 @@ const handleSellItem = async (item: Item) => {
};
// Use imported data instead of mock
const ADVENTURE_LOCATIONS = ADVENTURES;
const ACHIEVEMENTS_DATA = ACHIEVEMENTS;
const updateAchievementsState = () => {
if (achievementSystem.value) {
const all = achievementSystem.value.achievements;
achievementsState.value = all.map(achievement => {
const progress = achievementSystem.value!.getAchievementProgress(achievement);
const isUnlocked = achievementSystem.value!.unlockedAchievements.includes(achievement.id);
return {
...achievement,
unlocked: isUnlocked,
currentValue: progress.current,
maxValue: progress.target,
progress: Math.floor(progress.progress || 0)
};
});
}
};
// Convert ITEMS object to array for shop
const SHOP_ITEMS = Object.values(ITEMS).filter((item: any) => item.type === 'consumable' || item.type === 'equipment').map((item: any) => ({

View File

@ -5,6 +5,7 @@ import { FATES } from '../data/fates.js'
import { DEITIES } from '../data/deities.js'
import { DeitySystem } from './deity-system.js'
import { QUEST_TYPES } from '../data/deity-quests.js'
import { ENEMIES } from '../data/enemies.js'
export class PetSystem {
constructor(api = apiService, achievementSystem = null, inventorySystem = null) {
@ -1068,6 +1069,7 @@ export class PetSystem {
_autoSlept: willRandomSleep ? true : (newIsSleeping ? false : undefined)
})
// 記錄到成就系統
if (this.achievementSystem && newIsSleeping) {
await this.achievementSystem.recordAction('sleep')
@ -1080,5 +1082,169 @@ export class PetSystem {
randomSleep: willRandomSleep
}
}
// --- COMBAT SYSTEM ---
startCombat(enemyId) {
const enemyConfig = ENEMIES[enemyId];
if (!enemyConfig) return { success: false, message: '找不到敵人' };
// Initialize combat state
this.state.combat = {
isActive: true,
enemyId: enemyId,
enemyHp: enemyConfig.stats.hp,
enemyMaxHp: enemyConfig.stats.hp,
round: 0,
logs: [`遭遇 ${enemyConfig.name}!戰鬥開始!`],
rewards: { exp: 0, items: [], gold: 0 } // Track rewards
};
return { success: true, combatState: this.state.combat };
}
combatRound() {
if (!this.state.combat || !this.state.combat.isActive) return null;
const enemy = ENEMIES[this.state.combat.enemyId];
// Ensure combat stats are up to date
this.calculateCombatStats();
// Calculate effective stats including buffs and equipment
const pStats = {
atk: this.state.attack || (this.state.strength * 2),
def: this.state.defense || this.state.intelligence,
spd: this.state.speed || this.state.agility,
hp: this.state.health,
luck: this.state.effectiveLuck || this.state.luck || 0
};
// Apply temporary buffs (e.g., from food or items)
if (this.state.buffs) {
if (this.state.buffs.attackBoost) pStats.atk += this.state.buffs.attackBoost;
if (this.state.buffs.defenseBoost) pStats.def += this.state.buffs.defenseBoost;
}
const eStats = enemy.stats;
const logs = [];
this.state.combat.round++;
logs.push(`--- 第 ${this.state.combat.round} 回合 ---`);
// HP Recovery (Start of Turn)
const hpRecovery = (this.state.buffs?.healthRecovery || 0);
if (hpRecovery > 0 && this.state.health < 100) {
const recovered = Math.min(100 - this.state.health, hpRecovery);
this.state.health += recovered;
logs.push(`💚 回復了 ${recovered.toFixed(1)} HP`);
}
// Player Turn - Calculate Attack Count based on Speed
let attackCount = 1;
if (pStats.spd > eStats.speed) {
const speedDiff = pStats.spd - eStats.speed;
attackCount += Math.floor(speedDiff / 10);
logs.push(`⚡ 速度優勢!你可以攻擊 ${attackCount} 次!`);
}
let totalPlayerDamage = 0;
for (let i = 0; i < attackCount; i++) {
const hitChance = 0.9 + (pStats.spd - eStats.speed) * 0.01;
if (Math.random() < hitChance) {
const isCrit = Math.random() < (pStats.luck * 0.01);
let dmg = Math.max(1, pStats.atk - eStats.defense);
// Random Variance (±10%)
const variance = 0.9 + Math.random() * 0.2;
dmg = Math.floor(dmg * variance);
if (isCrit) {
dmg = Math.floor(dmg * 1.5);
logs.push(`💥 會心一擊!造成了 ${dmg} 點傷害!`);
} else {
logs.push(`⚔️ 你攻擊了 ${enemy.name},造成 ${dmg} 點傷害`);
}
this.state.combat.enemyHp -= dmg;
totalPlayerDamage += dmg;
} else {
logs.push(`💨 攻擊落空了!`);
}
if (this.state.combat.enemyHp <= 0) break;
}
// Check Enemy Death
if (this.state.combat.enemyHp <= 0) {
this.state.combat.enemyHp = 0;
this.state.combat.isActive = false;
logs.push(`🏆 你擊敗了 ${enemy.name}`);
// Full HP Recovery on Win
this.state.health = 100;
logs.push(`💖 勝利讓你的 HP 完全恢復了!`);
// Rewards
this.handleCombatRewards(enemy, logs, pStats.luck);
this.state.combat.logs = [...this.state.combat.logs, ...logs];
return { isOver: true, win: true, logs, rewards: this.state.combat.rewards };
}
// Enemy Turn
logs.push(`${enemy.name} 發動攻擊!`);
let enemyDmg = Math.max(1, eStats.attack - pStats.def);
// Damage Reduction from Buffs
if (this.state.buffs?.damageReduction) {
enemyDmg = Math.floor(enemyDmg * (1 - this.state.buffs.damageReduction));
}
this.state.health = Math.max(0, this.state.health - enemyDmg);
logs.push(`💔 你受到了 ${enemyDmg} 點傷害!(HP: ${this.state.health.toFixed(1)})`);
// Check Player Death
if (this.state.health <= 0) {
this.state.combat.isActive = false;
logs.push(`💀 你被 ${enemy.name} 擊敗了...`);
this.state.combat.logs = [...this.state.combat.logs, ...logs];
return { isOver: true, win: false, logs };
}
this.state.combat.logs = [...this.state.combat.logs, ...logs];
return { isOver: false, logs };
}
handleCombatRewards(enemy, logs, luck) {
// Exp (based on enemy HP/Stats)
const expGain = Math.floor(enemy.stats.hp / 10) + 5;
this.state.trainingExp += expGain;
this.state.combat.rewards.exp = expGain;
logs.push(`✨ 獲得了 ${expGain} 經驗值!`);
// Drops
if (enemy.drops) {
enemy.drops.forEach(drop => {
// Luck increases drop chance
const dropChance = drop.chance * (1 + luck * 0.01);
if (Math.random() < dropChance) {
this.addItem(drop.itemId, drop.count || 1);
this.state.combat.rewards.items.push({ id: drop.itemId, count: drop.count || 1 });
logs.push(`🎁 獲得了 ${drop.itemId} x${drop.count || 1}`);
}
});
}
}
// Helper to add item (simplified)
addItem(itemId, count) {
if (!this.state.inventory) this.state.inventory = [];
const existing = this.state.inventory.find(i => i.id === itemId);
if (existing) {
existing.count = (existing.count || 1) + count;
} else {
// Push a simple item object
this.state.inventory.push({ id: itemId, count });
}
}
}

View File

@ -66,8 +66,8 @@ export const ENEMIES = {
// 森林區敵人
snake: {
id: 'snake',
name: '毒蛇',
description: '潛伏在草叢中的危險掠食者。',
name: '青竹絲',
description: '台灣常見的毒蛇,潛伏在草叢中。',
stats: {
hp: 150,
attack: 35,
@ -75,23 +75,36 @@ export const ENEMIES = {
speed: 30
},
drops: [
{ itemId: 'vitality_potion', chance: 0.2, count: 1 },
{ itemId: 'magic_wand', chance: 0.05, count: 1 }
{ itemId: 'vitality_potion', chance: 0.2, count: 1 }
]
},
bear: {
id: 'bear',
name: '暴躁的黑熊',
description: '森林中的霸主,力量驚人。',
formosan_bear: {
id: 'formosan_bear',
name: '台灣黑熊',
description: '胸前有V字白毛的強壯黑熊是森林的守護者。',
stats: {
hp: 300,
attack: 50,
defense: 30,
speed: 10
hp: 500,
attack: 60,
defense: 40,
speed: 15
},
drops: [
{ itemId: 'gold_coin', chance: 0.5, count: 10 }, // 假設有金幣
{ itemId: 'hero_sword', chance: 0.02, count: 1 }
{ itemId: 'gold_coin', chance: 0.5, count: 50 },
{ itemId: 'hero_sword', chance: 0.05, count: 1 }
]
},
bad_spirit: {
id: 'bad_spirit',
name: '遊蕩的惡靈',
description: '在寺廟周圍徘徊的負面能量集合體。',
stats: {
hp: 200,
attack: 40,
defense: 5,
speed: 25
},
drops: [
{ itemId: 'lucky_charm', chance: 0.1, count: 1 }
]
}
}

62
data/locations.js Normal file
View File

@ -0,0 +1,62 @@
export const LOCATIONS = [
{
id: 'taipei_101',
name: '台北 101',
description: '繁華的信義區,高樓林立,充滿了現代氣息。',
backgroundImage: 'https://images.unsplash.com/photo-1596386461350-326256609fe1?q=80&w=800&auto=format&fit=crop',
costHunger: 0,
costGold: 0,
reqStats: null,
enemyPool: ['cockroach', 'stray_dog'],
enemyRate: 0.3,
events: [
{ type: 'text', text: '你在 101 前面看到很多觀光客拍照。' },
{ type: 'item', itemId: 'bubble_tea', chance: 0.1, text: '你撿到了一杯沒喝完的珍珠奶茶(還是別喝了吧...' }
]
},
{
id: 'night_market',
name: '饒河夜市',
description: '熱鬧非凡的夜市,空氣中飄散著臭豆腐和藥燉排骨的香氣。',
backgroundImage: 'https://images.unsplash.com/photo-1552423316-684441402375?q=80&w=800&auto=format&fit=crop',
costHunger: 0,
costGold: 0,
reqStats: null,
enemyPool: ['mouse', 'cockroach', 'stray_dog'],
enemyRate: 0.5,
events: [
{ type: 'text', text: '老闆熱情地招呼你:「帥哥/美女,來坐喔!」' },
{ type: 'buff', buffId: 'full', duration: 300, text: '你試吃了一口胡椒餅,感覺充滿了力量!' }
]
},
{
id: 'longshan_temple',
name: '龍山寺',
description: '香火鼎盛的古老寺廟,許多信徒在此虔誠祈禱。',
backgroundImage: 'https://images.unsplash.com/photo-1599926676326-877a54913416?q=80&w=800&auto=format&fit=crop',
costHunger: 10,
costGold: 0,
reqStats: null,
enemyPool: ['bad_spirit'],
enemyRate: 0.4,
events: [
{ type: 'text', text: '你聽到誦經聲,心靈感到平靜。' },
{ type: 'item', itemId: 'incense', chance: 0.2, text: '你獲得了一柱清香。' }
]
},
{
id: 'alishan',
name: '阿里山',
description: '雲霧繚繞的高山,擁有神木和日出美景。',
backgroundImage: 'https://images.unsplash.com/photo-1512453979798-5ea9ba6a80f4?q=80&w=800&auto=format&fit=crop',
costHunger: 20,
costGold: 10,
reqStats: { str: 50, int: 30 },
enemyPool: ['formosan_bear', 'snake'],
enemyRate: 0.6,
events: [
{ type: 'text', text: '你看到了壯觀的雲海。' },
{ type: 'text', text: '一隻獼猴搶走了你的香蕉!' }
]
}
];