fix: func

This commit is contained in:
王性驊 2025-11-26 14:53:44 +08:00
parent c29d80bd70
commit 873bc64cd2
36 changed files with 2340 additions and 7249 deletions

View File

@ -1,64 +1,39 @@
<template>
<div class="app-root">
<div>
<NuxtPage />
</div>
</template>
<style>
/* Global Styles for Pixel Art */
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:root {
--pixel-border: 3px;
--pixel-shadow: 4px 4px 0px rgba(0, 0, 0, 0.5);
/* 復古暗色調 RPG 配色 */
--color-bg: #6b6250;
--color-panel: #3a3430;
--color-panel-light: #4a4440;
--color-border: #2a2420;
--color-accent: #e89547;
--color-accent-dark: #d17a2e;
--color-text: #f4e4c1;
--color-text-dark: #8b7355;
/* 進度條與狀態 */
--color-hp: #c44032;
--color-hunger: #e89547;
--color-happy: #74b9ff;
--color-energy: #55efc4;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Press Start 2P', cursive;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
background-color: #1b1026;
color: #e0d8f0;
font-family: 'Press Start 2P', monospace;
}
/* 響應式字體調整 */
@media (max-width: 480px) {
body {
font-size: 10px;
line-height: 1.5;
}
/* Scrollbar Styling */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #0f0816;
border-left: 1px solid #2b193f;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #4a3b5e;
border: 1px solid #2b193f;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #8f80a0;
}
@media (min-width: 481px) and (max-width: 768px) {
body {
font-size: 11px;
}
}
.app-root {
min-height: 100vh;
min-height: -webkit-fill-available; /* iOS Safari 支援 */
/* Pixelated Image Rendering */
.image-pixelated {
image-rendering: pixelated;
}
</style>

View File

@ -0,0 +1,18 @@
@import "tailwindcss";
@theme {
/* Pixel Dungeon Color Palette */
--color-pixel-white: #e0d8f0;
--color-pixel-black: #1b1026;
--color-pixel-bg: #0f0816;
--color-pixel-primary: #f6b26b;
--color-pixel-secondary: #2ce8f4;
--color-pixel-accent: #d95763;
--color-pixel-green: #99e550;
--color-pixel-yellow: #ffe762;
--color-pixel-purple: #8f80a0;
--color-pixel-dark-purple: #4a3b5e;
--color-pixel-panel: #2b193f;
--color-pixel-panel-dark: #1b1026;
--color-pixel-panel-border: #4a3b5e;
}

View File

@ -0,0 +1,112 @@
<template>
<div class="flex flex-col gap-4 h-full">
<!-- Header Stats -->
<div class="flex justify-between items-center bg-[#231533] p-3 border-2 border-[#4a3b5e]">
<div class="flex gap-4">
<span class="text-[#99e550] tracking-widest uppercase">Unlocked: {{ unlocked }}/{{ total }}</span>
</div>
<div class="w-1/3 flex items-center gap-2">
<span class="text-[#99e550] tracking-widest uppercase text-sm whitespace-nowrap">Progress: {{ percentage }}%</span>
<RetroProgressBar :progress="percentage" color="#99e550" />
</div>
</div>
<div class="bg-[#0f0816] p-2 border-l-4 border-[#99e550] mb-2">
<h3 class="text-[#9fd75b] text-lg font-bold flex items-center gap-2">
<span class="text-xl"></span> ACHIEVEMENT LIST ({{ unlocked }}/{{ total }})
</h3>
</div>
<!-- Grid Layout for Achievements -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 overflow-y-auto pb-4">
<div
v-for="achievement in achievements"
:key="achievement.id"
class="relative border-2 p-3 flex flex-col gap-2 min-h-[140px] transition-all group hover:bg-[#231533]"
:style="{ borderColor: achievement.unlocked ? '#99e550' : '#4a3b5e', backgroundColor: achievement.unlocked ? '#150c1f' : '#0f0816' }"
>
<!-- Header: Icon + Title -->
<div class="flex items-start gap-3">
<div class="p-2 rounded-sm border-2" :class="achievement.unlocked ? 'border-[#99e550] bg-[#4b692f]/20' : 'border-[#4a3b5e] bg-[#2b193f]'">
<component :is="ICON_MAP[achievement.icon] || Trophy" :size="24" :color="achievement.unlocked ? achievement.color || '#ffe762' : '#8f80a0'" />
</div>
<div class="flex flex-col">
<h4 class="font-bold tracking-wide leading-none mb-1" :class="achievement.unlocked ? 'text-[#2ce8f4]' : 'text-[#8f80a0]'">
{{ achievement.title }}
</h4>
<span v-if="achievement.unlocked" class="text-[10px] text-[#99e550] uppercase tracking-widest">Completed</span>
<span v-else class="text-[10px] text-[#8f80a0] uppercase tracking-widest flex items-center gap-1">
<Lock :size="10" /> Locked
</span>
</div>
</div>
<!-- Description -->
<p class="text-xs text-[#e0d8f0] flex-grow leading-tight">
{{ achievement.description }}
</p>
<!-- Reward Section (if exists) -->
<div v-if="achievement.reward" class="text-[10px] text-[#99e550]">
<span class="text-[#99e550] opacity-70">Reward: </span>
{{ achievement.reward }}
</div>
<!-- Progress Bar (if incomplete) -->
<div v-if="!achievement.unlocked && achievement.maxValue" class="mt-auto">
<div class="flex justify-between text-[9px] text-[#8f80a0] mb-0.5">
<span>{{ achievement.currentValue }} / {{ achievement.maxValue }}</span>
<span>{{ achievement.progress }}%</span>
</div>
<div class="h-1 bg-[#2b193f] w-full">
<div class="h-full bg-[#4a3b5e]" :style="{ width: `${achievement.progress}%` }" />
</div>
</div>
<!-- Decorative corner if unlocked -->
<div v-if="achievement.unlocked" class="absolute top-0 right-0 w-4 h-4 overflow-hidden">
<div class="absolute top-0 right-0 w-2 h-2 bg-[#99e550]" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { CheckCircle2, Lock, Trophy, Baby, CalendarDays, Egg, Sprout, Cake, Star, Diamond, Milk, Utensils, Gamepad2, Sparkles, BookOpen, Search, Leaf, Dumbbell, Brush, Pill } from 'lucide-vue-next';
import RetroProgressBar from './RetroProgressBar.vue'; // Need to create this one too!
import type { Achievement } from '~/types/pixel';
interface Props {
achievements: Achievement[];
}
const props = defineProps<Props>();
const ICON_MAP: Record<string, any> = {
baby: Baby,
calendar: CalendarDays,
egg: Egg,
sprout: Sprout,
cake: Cake,
star: Star,
diamond: Diamond,
milk: Milk,
utensils: Utensils,
gamepad: Gamepad2,
sparkles: Sparkles,
book: BookOpen,
search: Search,
leaf: Leaf,
dumbbell: Dumbbell,
brush: Brush,
pill: Pill,
trophy: Trophy,
};
const total = computed(() => props.achievements.length);
const unlocked = computed(() => props.achievements.filter(a => a.unlocked).length);
const percentage = computed(() => Math.round((unlocked.value / total.value) * 100));
</script>

View File

@ -0,0 +1,146 @@
<template>
<div class="h-full flex flex-col p-4 gap-4 bg-[#1b1026] overflow-y-auto custom-scrollbar">
<!-- Table Background styling -->
<div class="absolute inset-0 bg-[#231533] opacity-50 pointer-events-none" />
<!-- Main Grid Layout -->
<div class="flex-grow grid grid-cols-12 gap-2 z-10">
<!-- Left: Hand (Col 3) -->
<div class="col-span-3 flex flex-col gap-2">
<PixelFrame title="HAND" class="h-full bg-[#1b1026]" variant="inset">
<div class="flex flex-col gap-2 h-full overflow-y-auto pr-1 custom-scrollbar">
<div v-for="(card, i) in handCards" :key="i" class="bg-[#2b193f] border-2 border-[#4a3b5e] p-1.5 flex flex-col hover:-translate-y-1 transition-transform cursor-pointer group shadow-lg">
<div class="flex justify-between items-start mb-1">
<div class="w-5 h-5 bg-[#1b1026] flex items-center justify-center rounded-sm">
<span class="text-[#f6b26b] text-[10px] font-bold">{{ card.cost }}</span>
</div>
<component :is="card.icon" :size="14" :color="card.color" />
</div>
<span class="text-xs text-[#e0d8f0] uppercase tracking-wide group-hover:text-[#f6b26b]">{{ card.name }}</span>
</div>
<!-- Empty Slot -->
<div class="border-2 border-dashed border-[#4a3b5e] rounded h-16 opacity-30 flex items-center justify-center text-xs">EMPTY</div>
</div>
</PixelFrame>
</div>
<!-- Center: Action Grid (Col 6) -->
<div class="col-span-6 flex flex-col relative">
<PixelFrame class="h-full bg-[#2b193f]">
<div class="grid grid-cols-4 grid-rows-3 gap-2 h-full p-1">
<template v-for="(action, index) in gridItems" :key="index">
<button
v-if="action"
@click="handleActionClick(action.id)"
class="relative bg-[#1b1026] border-2 border-[#4a3b5e] hover:border-[#f6b26b] hover:bg-[#231533] active:bg-[#f6b26b] active:border-[#f6b26b] group flex flex-col items-center justify-center p-1 transition-colors"
>
<div class="mb-1 p-1 rounded-sm bg-[#231533] group-active:bg-[#1b1026]">
<component :is="action.icon" :size="20" :color="action.color" class="group-active:text-[#f6b26b]" />
</div>
<span class="text-[9px] md:text-[10px] text-[#8f80a0] uppercase tracking-wider group-hover:text-white group-active:text-[#1b1026] font-bold text-center leading-tight">
{{ action.label }}
</span>
<!-- Corner deco -->
<div class="absolute top-0 right-0 w-1 h-1 bg-[#4a3b5e] group-hover:bg-[#f6b26b]" />
<div class="absolute bottom-0 left-0 w-1 h-1 bg-[#4a3b5e] group-hover:bg-[#f6b26b]" />
</button>
<div v-else class="bg-[#150c1f] border-2 border-[#2b193f] flex items-center justify-center opacity-50 cursor-not-allowed">
<div class="w-2 h-2 bg-[#2b193f] rounded-full"></div>
</div>
</template>
</div>
</PixelFrame>
</div>
<!-- Right: Stats & EQ (Col 3) -->
<div class="col-span-3 flex flex-col gap-2">
<!-- Stats Table -->
<PixelFrame title="STATS" class="bg-[#1b1026] h-2/3" variant="inset">
<div class="grid grid-cols-2 gap-x-2 content-start h-full p-1 overflow-y-auto custom-scrollbar">
<div v-for="(stat, i) in statsList" :key="i" class="flex justify-between items-center border-b border-[#2b193f] pb-0.5 mb-0.5">
<span class="text-[#8f80a0] text-[9px]">{{ stat.l }}</span>
<span class="font-mono text-[10px]" :style="{ color: stat.c || '#e0d8f0' }">{{ stat.v }}</span>
</div>
</div>
</PixelFrame>
<!-- Equipment Grid -->
<PixelFrame title="EQ" class="bg-[#1b1026] h-1/3" variant="inset">
<div
class="grid grid-cols-4 gap-1 h-full content-center cursor-pointer relative group"
@click="$emit('openInventory')"
title="Open Backpack"
>
<div v-for="(Icon, idx) in [Crown, Shirt, Hand, Footprints]" :key="idx" class="aspect-square bg-[#2b193f] border border-[#4a3b5e] flex items-center justify-center group-hover:border-[#f6b26b] transition-colors">
<component :is="Icon" :size="12" class="text-[#5a4b6e] group-hover:text-[#e0d8f0]" />
</div>
<!-- Hover Hint -->
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center text-[10px] text-[#f6b26b] font-bold pointer-events-none">
OPEN
</div>
</div>
</PixelFrame>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import PixelFrame from './PixelFrame.vue';
import {
Sword, Shield, FlaskConical, Crown, Hand, Footprints, Shirt,
Utensils, Gamepad2, Dumbbell, Puzzle, Brush, Pill, Sun, Sparkles, ShoppingBag, Swords
} from 'lucide-vue-next';
import type { EntityStats } from '~/types/pixel';
interface Props {
playerStats?: EntityStats;
}
const props = defineProps<Props>();
const emit = defineEmits(['openInventory', 'openGodSystem', 'openShop', 'openAdventure']);
const ACTIONS = [
{ id: 'feed', icon: Utensils, color: '#9fd75b', label: 'FEED 餵食' },
{ id: 'play', icon: Gamepad2, color: '#f6b26b', label: 'PLAY 玩耍' },
{ id: 'train', icon: Dumbbell, color: '#d75b5b', label: 'TRAIN 訓練' },
{ id: 'puzzle', icon: Puzzle, color: '#2ce8f4', label: 'PUZZLE 益智' },
{ id: 'clean', icon: Brush, color: '#8f80a0', label: 'CLEAN 清理' },
{ id: 'heal', icon: Pill, color: '#9fd75b', label: 'HEAL 治療' },
{ id: 'fight', icon: Swords, color: '#d95763', label: 'FIGHT 戰鬥' },
{ id: 'wake', icon: Sun, color: '#ffe762', label: 'WAKE 起床' },
{ id: 'pray', icon: Sparkles, color: '#e0d8f0', label: 'PRAY 祈福' },
{ id: 'shop', icon: ShoppingBag, color: '#ffa500', label: 'SHOP 商店' },
];
const gridItems = computed(() => {
return Array.from({ length: 12 }).map((_, i) => ACTIONS[i] || null);
});
const handCards = [
{ name: 'Slash', cost: 2, icon: Sword, color: '#d75b5b' },
{ name: 'Block', cost: 1, icon: Shield, color: '#f6b26b' },
{ name: 'Heal', cost: 3, icon: FlaskConical, color: '#9fd75b' }
];
const statsList = computed(() => {
const s = props.playerStats || { str:0, int:0, dex:0, luck:0, atk:0, def:0, spd:0 };
return [
{l:'STR', v:s.str}, {l:'ATK', v:s.atk, c: '#d75b5b'},
{l:'INT', v:s.int}, {l:'DEF', v:s.def, c: '#f6b26b'},
{l:'DEX', v:s.dex}, {l:'SPD', v:s.spd},
{l:'LCK', v:s.luck},
];
});
const handleActionClick = (id: string) => {
if (id === 'pray') emit('openGodSystem');
else if (id === 'shop') emit('openShop');
else if (id === 'fight') emit('openAdventure');
};
</script>

View File

@ -0,0 +1,95 @@
<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
v-for="loc in locations"
:key="loc.id"
class="border-2 p-4 relative transition-all"
:class="isLocked(loc) ? 'border-gray-600 opacity-70' : 'border-[#99e550] hover:bg-[#0f2a0f]'"
>
<!-- Title -->
<div class="text-xl font-bold tracking-widest mb-2 text-[#99e550]">
{{ loc.name }}
</div>
<!-- Description -->
<p class="text-xs text-white mb-4 text-center">
{{ loc.description }}
</p>
<!-- Costs & Reqs -->
<div class="flex flex-wrap gap-4 mb-4 text-sm font-mono">
<div class="flex items-center gap-2">
<span class="text-[#99e550]">消耗:</span>
<div class="flex items-center gap-1" :class="canAffordHunger(loc) ? 'text-[#9fd75b]' : 'text-red-500'">
<Drumstick :size="14" /> {{ loc.costHunger }}
</div>
<div class="flex items-center gap-1" :class="canAffordGold(loc) ? 'text-[#f6b26b]' : 'text-red-500'">
<Coins :size="14" /> {{ loc.costGold }}
</div>
</div>
<div v-if="loc.reqStats" class="flex items-center gap-2">
<span class="text-[#99e550]">要求:</span>
<span v-if="loc.reqStats.str" :class="meetsStr(loc) ? 'text-[#9fd75b]' : 'text-red-500'">STR {{ loc.reqStats.str }}</span>
<span v-if="loc.reqStats.int" :class="meetsInt(loc) ? 'text-[#9fd75b]' : 'text-red-500'">INT {{ loc.reqStats.int }}</span>
</div>
</div>
<!-- Action Button -->
<button
@click="!isLocked(loc) && canAfford(loc) && $emit('selectLocation', loc)"
:disabled="!canAfford(loc) || isLocked(loc)"
class="w-full py-2 text-lg tracking-[0.2em] border"
: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) ? "資源不足" : "出發 !" }}
</button>
<!-- 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 } from 'lucide-vue-next';
import type { AdventureLocation, EntityStats } from '~/types/pixel';
interface Props {
locations: AdventureLocation[];
playerStats: EntityStats;
}
const props = defineProps<Props>();
defineEmits(['selectLocation', 'close']);
const canAffordHunger = (loc: AdventureLocation) => (props.playerStats.hunger || 0) >= loc.costHunger;
const canAffordGold = (loc: AdventureLocation) => (props.playerStats.gold || 0) >= loc.costGold;
const meetsStr = (loc: AdventureLocation) => !loc.reqStats?.str || (props.playerStats.str || 0) >= loc.reqStats.str;
const meetsInt = (loc: AdventureLocation) => !loc.reqStats?.int || (props.playerStats.int || 0) >= loc.reqStats.int;
const isLocked = (loc: AdventureLocation) => !meetsStr(loc) || !meetsInt(loc);
const canAfford = (loc: AdventureLocation) => canAffordHunger(loc) && canAffordGold(loc);
</script>

View File

@ -0,0 +1,99 @@
<template>
<div v-if="isFighting" class="h-full w-full bg-black p-4 font-mono overflow-hidden flex flex-col border-b-4 border-[#99e550]">
<div class="text-[#99e550] text-sm mb-2 border-b border-gray-700 pb-1 flex justify-between animate-pulse">
<span> BATTLE_LOG_V1.0</span>
<span>RECORDING...</span>
</div>
<div class="flex-grow overflow-y-auto custom-scrollbar flex flex-col gap-1 pr-2">
<div v-for="(log, index) in battleLogs" :key="index" class="text-sm md:text-base leading-tight">
<span class="text-gray-500 mr-2">[{{ index + 1 }}]</span>
<span v-if="log.includes('Victory') || log.includes('Success')" class="text-[#2ce8f4] font-bold">{{ log }}</span>
<span v-else-if="log.includes('damage') || log.includes('Hurt')" class="text-[#d95763]">{{ log }}</span>
<span v-else-if="log.includes('used')" class="text-[#f6b26b]">{{ log }}</span>
<span v-else class="text-[#99e550]">{{ log }}</span>
</div>
<div ref="logEndRef" />
</div>
</div>
<!-- Room Mode (Default) -->
<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"
: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)'
}"
/>
<!-- Pixelated Dither Overlay -->
<div
class="absolute inset-0 opacity-10 pointer-events-none"
:style="{
backgroundImage: `repeating-linear-gradient(45deg, #000 0, #000 1px, transparent 0, transparent 50%)`,
backgroundSize: '4px 4px'
}"
/>
<!-- Room Decor - Ground Rug/Circle -->
<div class="absolute bottom-12 left-1/2 transform -translate-x-1/2 w-48 h-12 bg-[#2b193f] opacity-60 rounded-[50%] border-2 border-[#4a3b5e] shadow-[0_0_20px_rgba(0,0,0,0.5)]"></div>
<!-- Battle Log Mode (If Fighting) -->
<div v-if="isFighting" class="absolute inset-0 bg-black/80 backdrop-blur-sm p-6">
<PixelFrame class="h-full flex flex-col">
<div class="text-[#2ce8f4] text-sm mb-4 uppercase tracking-widest font-bold">
BATTLE LOG
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar space-y-2 text-xs">
<div
v-for="(log, idx) in battleLogs"
:key="idx"
class="text-[#e0d8f0] animate-fade-in"
>
{{ log }}
</div>
</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';
import type { DeityId } from '~/types/pixel';
interface Props {
currentDeityId?: string;
isFighting?: boolean;
battleLogs?: string[];
}
const props = withDefaults(defineProps<Props>(), {
isFighting: false,
battleLogs: () => []
});
const logEndRef = ref<HTMLDivElement | null>(null);
watch(() => props.battleLogs, async () => {
await nextTick();
if (logEndRef.value) {
logEndRef.value.scrollIntoView({ behavior: 'smooth' });
}
}, { deep: true });
</script>

View File

@ -0,0 +1,322 @@
<template>
<div class="flex flex-col h-full gap-4">
<!-- Header: Deity System Title -->
<div class="text-xl font-bold tracking-widest text-[#2ce8f4] border-b-2 border-[#4a3b5e] pb-2">
[GOD SYSTEM] 神明系統
</div>
<!-- Top Action Buttons Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<PixelButton
v-for="action in TAB_ACTIONS"
:key="action.id"
@click="activeTab = action.id"
:variant="activeTab === action.id ? 'primary' : 'secondary'"
class="text-xs md:text-sm flex items-center justify-center gap-2"
>
<component :is="action.icon" :size="16" />
{{ action.label }}
</PixelButton>
<PixelButton :variant="activeTab === 'LIST' ? 'primary' : 'secondary'" @click="activeTab = 'LIST'" class="text-xs md:text-sm">
[LIST] 神明列表
</PixelButton>
</div>
<!-- Main Content Area -->
<div class="flex-grow bg-[#150c1f] border border-[#4a3b5e] p-4 relative overflow-hidden">
<!-- Background Decor -->
<div class="absolute inset-0 opacity-20 pointer-events-none flex items-center justify-center">
<div class="w-64 h-64 border-[20px] border-[#2b193f] rounded-full"></div>
</div>
<!-- --- VIEW: PRAY --- -->
<div v-if="activeTab === 'PRAY'" class="flex flex-col items-center justify-center h-full gap-6">
<div class="transform scale-150 mb-4">
<PixelAvatar :deityId="currentDeity" />
</div>
<h2 class="text-2xl text-[#f6b26b] font-bold">{{ activeDeity.title }} {{ activeDeity.name }}</h2>
<div class="w-full max-w-md">
<div class="flex justify-between text-xs text-[#8f80a0] mb-1">
<span>FAVOR (好感度)</span>
<span>{{ activeDeity.favor }}/{{ activeDeity.maxFavor }}</span>
</div>
<div class="h-4 bg-[#2b193f] border border-[#4a3b5e] rounded-full overflow-hidden relative">
<div
class="h-full bg-[#d95763] transition-all duration-500"
:style="{ width: `${(activeDeity.favor / activeDeity.maxFavor) * 100}%` }"
></div>
</div>
</div>
<PixelButton class="w-48 py-4 text-lg animate-pulse" @click="$emit('addFavor', 10)">
🙏 PRAY (祈福)
</PixelButton>
<p class="text-xs text-[#8f80a0]">Increases favor with {{ activeDeity.name }}</p>
</div>
<!-- --- VIEW: JIAOBEI (Free Toss) --- -->
<div v-else-if="activeTab === 'JIAOBEI'" class="flex flex-col items-center justify-center h-full gap-8">
<h3 class="text-[#f6b26b] text-lg uppercase tracking-widest">Moon Block Divination</h3>
<JiaobeiBlocks :result="lastResult" :isTossing="isTossing" />
<div class="mt-8">
<PixelButton @click="handleToss(false)" :disabled="isTossing" class="w-40">
{{ isTossing ? 'TOSSING...' : 'TOSS BLOCKS' }}
</PixelButton>
</div>
</div>
<!-- --- VIEW: LOT (Draw & Verify) --- -->
<div v-else-if="activeTab === 'LOT'" class="flex flex-col items-center justify-center h-full gap-4 text-center w-full">
<!-- Phase: Idle -->
<template v-if="lotPhase === LotPhase.Idle">
<Scroll :size="64" class="text-[#ffe762] mb-4" />
<h3 class="text-xl text-[#e0d8f0] mb-2">Draw a Fortune Lot</h3>
<p class="text-sm text-[#8f80a0] max-w-xs mb-6">
Shake the container to draw a stick, then verify it with 3 consecutive Saint Cups.
</p>
<PixelButton @click="handleDrawLot" class="w-48">
DRAW LOT
</PixelButton>
</template>
<!-- Phase: Drawing (Animation) -->
<div v-else-if="lotPhase === LotPhase.Drawing" class="animate-bounce">
<div class="w-16 h-24 bg-[#4a2e18] border-2 border-[#f6b26b] mx-auto mb-4 relative rounded-sm">
<div class="absolute top-0 left-0 w-full h-full flex items-center justify-center text-[#f6b26b] font-bold">
...
</div>
</div>
<span class="text-[#f6b26b] tracking-widest">SHAKING...</span>
</div>
<!-- Phase: Verify -->
<template v-else-if="lotPhase === LotPhase.PendingVerify || lotPhase === LotPhase.Verifying">
<div class="text-2xl font-bold text-[#e0d8f0] border-2 border-[#f6b26b] px-4 py-2 mb-4 bg-[#2b193f]">
LOT #{{ drawnLotNumber }}
</div>
<p class="text-sm text-[#8f80a0] mb-4">
Verify with 3 Consecutive Saint Cups
</p>
<div class="flex gap-2 mb-6 justify-center">
<div
v-for="i in 3"
:key="i"
class="w-4 h-4 rounded-full border border-[#4a3b5e]"
:class="i <= saintCupCount ? 'bg-[#d95763] shadow-[0_0_10px_#d95763]' : 'bg-[#1b1026]'"
/>
</div>
<JiaobeiBlocks :result="lastResult" :isTossing="isTossing" />
<div class="mt-8">
<PixelButton @click="handleToss(true)" :disabled="isTossing" class="w-40">
VERIFY ({{ saintCupCount }}/3)
</PixelButton>
</div>
</template>
<!-- Phase: Failed -->
<template v-else-if="lotPhase === LotPhase.Failed">
<div class="text-[#d95763] text-4xl mb-4"></div>
<h3 class="text-lg text-[#d95763] mb-2">Not a Saint Cup</h3>
<p class="text-sm text-[#8f80a0] mb-6">
The deity indicates this is not the right lot.<br/>Please draw again.
</p>
<PixelButton @click="resetLot" variant="danger">
TRY AGAIN
</PixelButton>
</template>
<!-- Phase: Success - Detailed Result -->
<div v-else-if="lotPhase === LotPhase.Success" class="w-full h-full overflow-y-auto custom-scrollbar p-2">
<PixelFrame class="bg-[#1b1026] border-4 border-[#f6b26b] relative shadow-[0_0_20px_rgba(246,178,107,0.3)]">
<!-- Header -->
<div class="text-center border-b-2 border-[#4a3b5e] pb-3 mb-3 bg-[#231533] p-2">
<div class="text-[#99e550] text-sm md:text-lg font-bold mb-1 flex items-center justify-center gap-2 animate-pulse">
<Sparkles :size="16" />
<span>三聖筊{{ activeDeity.name }}允准解籤</span>
<Sparkles :size="16" />
</div>
<div class="text-[#f6b26b] text-xl md:text-3xl font-bold tracking-widest mt-2 font-serif">
{{ LOT_RESULT_DATA.number }} {{ LOT_RESULT_DATA.level }}
</div>
</div>
<!-- Poem (Block) -->
<div class="bg-[#2b193f] p-4 text-center mb-4 border-l-4 border-[#f6b26b] mx-2 shadow-inner">
<div v-for="(line, i) in LOT_RESULT_DATA.poem" :key="i" class="text-lg md:text-xl text-[#e0d8f0] tracking-[0.2em] leading-loose font-serif drop-shadow-md">
{{ line }}
</div>
</div>
<!-- Details Grid -->
<div class="flex flex-col gap-4 text-left px-2">
<!-- Meaning -->
<div class="bg-[#0f0816] p-2 border border-[#4a3b5e]">
<span class="text-[#f6b26b] font-bold text-sm block mb-1 border-b border-[#4a3b5e] pb-1 w-full">
解曰 Meaning
</span>
<p class="text-[#e0d8f0] text-sm leading-relaxed mt-1">{{ LOT_RESULT_DATA.meaning }}</p>
</div>
<!-- Interpretation -->
<div class="bg-[#0f0816] p-2 border border-[#4a3b5e]">
<span class="text-[#99e550] font-bold text-sm block mb-1 border-b border-[#4a3b5e] pb-1 w-full">
解籤 Interpretation
</span>
<p class="text-[#e0d8f0] text-sm leading-relaxed mt-1">{{ LOT_RESULT_DATA.interpretation }}</p>
</div>
<!-- Story -->
<div class="bg-[#0f0816] p-2 border border-[#4a3b5e]">
<span class="text-[#2ce8f4] font-bold text-sm block mb-1 border-b border-[#4a3b5e] pb-1 w-full">
典故 Story: {{ LOT_RESULT_DATA.storyTitle }}
</span>
<div class="text-[#8f80a0] text-xs leading-relaxed mt-1">
{{ LOT_RESULT_DATA.story }}
</div>
</div>
</div>
<div class="mt-6 flex justify-center mb-2">
<PixelButton @click="resetLot" class="w-full md:w-auto px-8 py-3">
收入背包 (KEEP LOT)
</PixelButton>
</div>
</PixelFrame>
</div>
</div>
<!-- --- VIEW: LIST/SWITCH --- -->
<div v-else-if="activeTab === 'LIST' || activeTab === 'VERIFY'" class="flex flex-col gap-4">
<div class="text-xs text-[#8f80a0] uppercase mb-2"> [SWITCH] 切換神明</div>
<div class="grid grid-cols-2 gap-4">
<button
v-for="deity in Object.values(deities)"
:key="deity.id"
@click="$emit('switchDeity', deity.id)"
class="border-2 p-3 flex items-center gap-3 transition-all relative"
:class="currentDeity === deity.id ? 'border-[#99e550] bg-[#2b193f]' : 'border-[#4a3b5e] bg-[#0f0816] hover:bg-[#150c1f]'"
>
<div class="w-10 h-10 relative">
<PixelAvatar :deityId="deity.id" />
</div>
<div class="flex flex-col items-start">
<span class="font-bold" :class="currentDeity === deity.id ? 'text-[#99e550]' : 'text-[#e0d8f0]'">
{{ deity.name }}
</span>
<span class="text-[10px] text-[#8f80a0]">{{ deity.title }}</span>
</div>
<div v-if="currentDeity === deity.id" class="absolute top-2 right-2 w-2 h-2 bg-[#99e550] rounded-full shadow-[0_0_5px_#99e550]"></div>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { Heart, Sparkles, Scroll, Repeat, CheckCircle2 } from 'lucide-vue-next';
import PixelButton from './PixelButton.vue';
import PixelAvatar from './PixelAvatar.vue';
import PixelFrame from './PixelFrame.vue';
import JiaobeiBlocks from './JiaobeiBlocks.vue';
import { DeityId, JiaobeiResult, LotPhase } from '~/types/pixel';
import type { Deity } from '~/types/pixel';
interface Props {
currentDeity: DeityId;
deities: Record<DeityId, Deity>;
}
const props = defineProps<Props>();
defineEmits(['switchDeity', 'addFavor']);
const activeTab = ref('PRAY');
const isTossing = ref(false);
const lastResult = ref<JiaobeiResult | null>(null);
const lotPhase = ref<LotPhase>(LotPhase.Idle);
const drawnLotNumber = ref<number | null>(null);
const saintCupCount = ref(0);
const TAB_ACTIONS = [
{ id: 'PRAY', label: 'PRAY (祈福)', icon: Sparkles },
{ id: 'LOT', label: 'LOT (求籤)', icon: Scroll },
{ id: 'VERIFY', label: 'VERIFY (驗證)', icon: CheckCircle2 },
{ id: 'JIAOBEI', label: 'JIAOBEI (擲筊)', icon: Repeat },
];
const LOT_RESULT_DATA = {
number: "第八十六籤",
level: "上籤",
poem: [
"春來花發映陽臺",
"萬里舟行進寶來",
"躍過禹門三級浪",
"恰如平地一聲雷"
],
meaning: "此卦上朝見帝之象。凡事太吉大利也。",
interpretation: "朝帝受職。如貧得寶。謀望從心。卦中第一。此籤從心所欲。諸事皆吉。",
storyTitle: "商絡中三元",
story: "三元記。明朝。商絡。浙江人。父早亡。商絡三元及第。喻步步高升也。(三元即三級試。鄉試解元。省試會元。殿試狀元)"
};
const activeDeity = computed(() => props.deities[props.currentDeity]);
const calculateToss = (): JiaobeiResult => {
const rand = Math.random();
if (rand < 0.5) return JiaobeiResult.Saint;
if (rand < 0.75) return JiaobeiResult.Smile;
return JiaobeiResult.Cry;
};
const handleToss = (isLotVerify = false) => {
if (isTossing.value) return;
isTossing.value = true;
lastResult.value = null;
setTimeout(() => {
const result = calculateToss();
isTossing.value = false;
lastResult.value = result;
if (isLotVerify) {
if (result === JiaobeiResult.Saint) {
saintCupCount.value++;
if (saintCupCount.value >= 3) {
lotPhase.value = LotPhase.Success;
}
} else {
lotPhase.value = LotPhase.Failed;
}
}
}, 1500);
};
const handleDrawLot = () => {
lotPhase.value = LotPhase.Drawing;
setTimeout(() => {
drawnLotNumber.value = Math.floor(Math.random() * 60) + 1;
lotPhase.value = LotPhase.PendingVerify;
saintCupCount.value = 0;
lastResult.value = null;
}, 2000);
};
const resetLot = () => {
lotPhase.value = LotPhase.Idle;
drawnLotNumber.value = null;
saintCupCount.value = 0;
lastResult.value = null;
};
</script>

View File

@ -0,0 +1,79 @@
<template>
<div class="h-full flex flex-col p-4 gap-4 bg-[#1b1026] overflow-y-auto custom-scrollbar">
<!-- Deity Portrait & Header -->
<PixelFrame class="flex-shrink-0" title="WORSHIP" highlight>
<div class="flex flex-col items-center p-1">
<div class="w-24 h-24 bg-[#1b1026] border-4 border-[#d75b5b] mb-2 p-1 relative flex items-center justify-center overflow-hidden">
<!-- Background for portrait -->
<div class="absolute inset-0 bg-[#3d2459] opacity-50" />
<!-- Deity Avatar -->
<div class="scale-125 transform translate-y-2">
<PixelAvatar :deityId="deity.id" />
</div>
</div>
<h2 class="text-lg text-[#f6b26b] tracking-widest uppercase font-bold text-center leading-tight">
{{ deity.name }}
</h2>
<div class="text-xs text-[#8f80a0] mt-1 text-center font-bold">
{{ deity.title }}
</div>
</div>
</PixelFrame>
<!-- Favor Bar -->
<div class="px-1 mt-1">
<RetroResourceBar
:current="deity.favor"
:max="deity.maxFavor"
type="energy"
label="Favor (好感度)"
:icon="Heart"
/>
</div>
<!-- Deity Details / Description -->
<PixelFrame variant="inset" class="mt-2 flex-grow overflow-y-auto custom-scrollbar">
<div class="flex flex-col gap-2 h-full p-1">
<div class="flex items-center gap-2 text-[#9fd75b] border-b border-[#4a3b5e] pb-1 sticky top-0 bg-[#150c1f] z-10">
<Sparkles :size="14" />
<span class="text-xs font-bold uppercase">Blessing</span>
</div>
<p class="text-xs text-[#e0d8f0] italic leading-relaxed">
"{{ deity.description }}"
</p>
<div class="mt-auto p-2 bg-[#231533] border border-[#4a3b5e]">
<span class="text-[10px] text-[#8f80a0] uppercase block mb-1">Current Effect:</span>
<span class="text-xs text-[#2ce8f4]">
{{ currentEffect }}
</span>
</div>
</div>
</PixelFrame>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Heart, Sparkles } from 'lucide-vue-next';
import PixelFrame from './PixelFrame.vue';
import RetroResourceBar from './RetroResourceBar.vue';
import PixelAvatar from './PixelAvatar.vue';
import type { Deity } from '~/types/pixel';
interface Props {
deity: Deity;
}
const props = defineProps<Props>();
const currentEffect = computed(() => {
if (props.deity.favor >= 80) return "Divine Protection (DEF +20%)";
if (props.deity.favor >= 50) return "Minor Blessing (LCK +5)";
return "None (Pray more!)";
});
</script>

View File

@ -0,0 +1,209 @@
<template>
<div class="flex flex-col h-full gap-2">
<!-- 1. Rarity Legend -->
<div class="flex flex-wrap gap-2 px-2 py-1 bg-[#150c1f] border border-[#4a3b5e] text-[10px]">
<span class="text-[#8f80a0] mr-2">Rarity:</span>
<div v-for="(color, rarity) in RARITY_COLORS" :key="rarity" class="flex items-center gap-1 border border-[#2b193f] px-1 bg-[#0f0816]">
<span :style="{ color: color }">{{ rarity }}</span>
<span class="text-[#4a3b5e]">(10%)</span>
</div>
</div>
<!-- 2. Equipment Slots Grid -->
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
<div v-for="slot in Object.values(EquipSlot)" :key="slot" class="border border-[#4a3b5e] bg-[#0f0816] p-2 flex flex-col gap-2 relative">
<!-- Slot Header -->
<div class="flex items-center gap-2 mb-1 justify-center border-b border-[#2b193f] pb-1">
<component :is="SLOT_ICONS[slot]" :size="14" class="text-[#8f80a0]" />
<span class="text-[#2ce8f4] text-xs font-bold uppercase tracking-wider">{{ slot }}</span>
</div>
<!-- Actual Slot -->
<div
class="bg-[#150c1f] border border-[#4a3b5e] p-2 min-h-[40px] flex flex-col items-center justify-center cursor-pointer hover:border-[#9fd75b] group"
@click="getEquippedItem(slot, false) && setSelectedItemId(getEquippedItem(slot, false)?.id)"
>
<span class="text-[9px] text-[#8f80a0] mb-0.5">ACTUAL</span>
<span v-if="getEquippedItem(slot, false)" class="text-xs text-center" :style="{ color: RARITY_COLORS[getEquippedItem(slot, false)!.rarity] }">{{ getEquippedItem(slot, false)!.name }}</span>
<span v-else class="text-[10px] text-[#4a3b5e]">Empty</span>
</div>
<!-- Appearance Slot -->
<div
class="bg-[#150c1f] border border-[#4a3b5e] p-2 min-h-[40px] flex flex-col items-center justify-center cursor-pointer hover:border-[#d584fb] group"
@click="getEquippedItem(slot, true) && setSelectedItemId(getEquippedItem(slot, true)?.id)"
>
<span class="text-[9px] text-[#8f80a0] mb-0.5">COSMETIC</span>
<span v-if="getEquippedItem(slot, true)" class="text-xs text-center" :style="{ color: RARITY_COLORS[getEquippedItem(slot, true)!.rarity] }">{{ getEquippedItem(slot, true)!.name }}</span>
<span v-else class="text-[10px] text-[#4a3b5e]">Empty</span>
</div>
</div>
</div>
<!-- 3. Backpack Section -->
<div class="flex-grow flex flex-col md:flex-row gap-2 overflow-hidden mt-2">
<!-- Item Grid -->
<PixelFrame class="flex-grow flex flex-col bg-[#1b1026]" :title="`Backpack (${items.filter(i => !i.isEquipped).length})`">
<div class="flex-grow overflow-y-auto p-1 custom-scrollbar">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
<button
v-for="item in items.filter(i => !i.isEquipped)"
:key="item.id"
@click="setSelectedItemId(item.id)"
class="relative p-2 flex flex-col items-center justify-center gap-1 min-h-[80px] border-2 transition-all group bg-[#2b193f]"
:class="selectedItemId === item.id ? 'border-white bg-[#3d2459]' : 'border-[#4a3b5e] hover:border-[#8f80a0]'"
>
<div class="relative">
<!-- Generic Icons based on type -->
<template v-if="item.type === ItemType.Equipment">
<Sword v-if="item.slot === EquipSlot.Weapon" :color="RARITY_COLORS[item.rarity]" />
<Shield v-else-if="item.slot === EquipSlot.Armor" :color="RARITY_COLORS[item.rarity]" />
<Crown v-else-if="item.slot === EquipSlot.Hat" :color="RARITY_COLORS[item.rarity]" />
<Gem v-else-if="item.slot === EquipSlot.Accessory" :color="RARITY_COLORS[item.rarity]" />
<Sparkles v-else-if="item.slot === EquipSlot.Charm" :color="RARITY_COLORS[item.rarity]" />
<Star v-else :color="RARITY_COLORS[item.rarity]" />
</template>
<template v-else>
<Zap v-if="item.name.includes('Potion')" :color="RARITY_COLORS[item.rarity]" />
<Heart v-else :color="RARITY_COLORS[item.rarity]" />
</template>
<span v-if="item.quantity && item.quantity > 1" class="absolute -bottom-2 -right-2 text-[10px] bg-black text-white px-1 border border-[#4a3b5e]">{{ item.quantity }}</span>
</div>
<span class="text-[10px] text-center leading-tight line-clamp-2" :style="{ color: RARITY_COLORS[item.rarity] }">
{{ item.name }}
</span>
</button>
</div>
</div>
</PixelFrame>
<!-- Selected Item Detail -->
<div class="w-full md:w-1/3 min-h-[200px] flex-shrink-0">
<PixelFrame v-if="selectedItem" class="h-full bg-[#150c1f] flex flex-col" highlight>
<!-- Item Header -->
<div class="flex gap-3 mb-2 border-b border-[#4a3b5e] pb-2">
<div class="w-12 h-12 bg-[#0f0816] border border-[#4a3b5e] flex items-center justify-center">
<Shirt v-if="selectedItem.type === ItemType.Equipment" :size="24" :color="RARITY_COLORS[selectedItem.rarity]" />
<Zap v-else :size="24" :color="RARITY_COLORS[selectedItem.rarity]" />
</div>
<div class="flex flex-col">
<span class="font-bold text-sm tracking-wide" :style="{ color: RARITY_COLORS[selectedItem.rarity] }">{{ selectedItem.name }}</span>
<div class="flex gap-2 text-[10px] text-[#8f80a0]">
<span>{{ selectedItem.rarity }}</span>
<span></span>
<span>{{ selectedItem.type }}</span>
</div>
</div>
</div>
<!-- Description -->
<div class="mb-2">
<p class="text-xs text-[#e0d8f0] italic mb-2">"{{ selectedItem.description }}"</p>
<!-- Stats Block -->
<div v-if="selectedItem.statsDescription" class="bg-[#0f0816] border border-[#4a3b5e] p-2 mb-2">
<span class="text-[10px] text-[#99e550] block mb-1">EFFECTS:</span>
<span class="text-xs text-[#2ce8f4]">{{ selectedItem.statsDescription }}</span>
</div>
<div v-if="selectedItem.effects && selectedItem.effects.length > 0" class="flex flex-col gap-1">
<span v-for="(eff, i) in selectedItem.effects" :key="i" class="text-[10px] text-[#9fd75b]">+ {{ eff }}</span>
</div>
</div>
<!-- Actions -->
<div class="mt-auto flex flex-col gap-2">
<div v-if="selectedItem.type === ItemType.Equipment" class="grid grid-cols-2 gap-2">
<PixelButton
class="text-[10px] py-1"
:disabled="selectedItem.isEquipped && !selectedItem.isAppearance"
@click="$emit('equip', selectedItem.id, false)"
>
{{ selectedItem.isEquipped && !selectedItem.isAppearance ? 'EQUIPPED' : 'EQUIP' }}
</PixelButton>
<PixelButton
variant="secondary"
class="text-[10px] py-1"
:disabled="selectedItem.isEquipped && selectedItem.isAppearance"
@click="$emit('equip', selectedItem.id, true)"
>
COSMETIC
</PixelButton>
</div>
<PixelButton v-if="selectedItem.type === ItemType.Consumable" @click="$emit('use', selectedItem.id)">USE ITEM</PixelButton>
<div class="flex justify-between mt-2 pt-2 border-t border-[#4a3b5e]">
<button
v-if="selectedItem.isEquipped"
@click="$emit('unequip', selectedItem.slot!, selectedItem.isAppearance!)"
class="text-[#f6b26b] text-xs hover:underline"
>
Unequip
</button>
<button
@click="$emit('delete', selectedItem.id)"
class="text-[#d95763] text-xs hover:text-red-400 flex items-center gap-1 ml-auto"
>
<Trash2 :size="10" /> Delete
</button>
</div>
</div>
</PixelFrame>
<PixelFrame v-else class="h-full bg-[#150c1f] flex items-center justify-center text-[#4a3b5e]">
<div class="text-center">
<HelpCircle :size="32" class="mx-auto mb-2 opacity-50" />
<span class="text-xs">Select an item<br/>to view details</span>
</div>
</PixelFrame>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { Sword, Shield, Crown, Gem, Sparkles, Star, Shirt, HelpCircle, Trash2, Zap, Heart } from 'lucide-vue-next';
import PixelFrame from './PixelFrame.vue';
import PixelButton from './PixelButton.vue';
import { ItemType, EquipSlot, Rarity } from '~/types/pixel';
import type { Item } from '~/types/pixel';
interface Props {
items: Item[];
}
const props = defineProps<Props>();
defineEmits(['equip', 'unequip', 'use', 'delete']);
const selectedItemId = ref<string | null>(null);
const selectedItem = computed(() => props.items.find(i => i.id === selectedItemId.value));
const RARITY_COLORS: Record<Rarity, string> = {
[Rarity.Common]: '#9ca3af', // Gray
[Rarity.Excellent]: '#9fd75b', // Green
[Rarity.Rare]: '#2ce8f4', // Blue
[Rarity.Epic]: '#d584fb', // Purple
[Rarity.Legendary]: '#ffa500', // Orange
};
const SLOT_ICONS: Record<EquipSlot, any> = {
[EquipSlot.Weapon]: Sword,
[EquipSlot.Armor]: Shield,
[EquipSlot.Hat]: Crown,
[EquipSlot.Accessory]: Gem,
[EquipSlot.Charm]: Sparkles,
[EquipSlot.Special]: Star,
};
const getEquippedItem = (slot: EquipSlot, isAppearance: boolean) => {
return props.items.find(i => i.isEquipped && i.slot === slot && !!i.isAppearance === isAppearance);
};
</script>

View File

@ -0,0 +1,54 @@
<template>
<div class="flex gap-8 transition-transform duration-500" :class="{ 'animate-spin': isTossing }">
<template v-if="result === 'Saint'">
<div class="w-12 h-20 bg-[#d95763] rounded-full border-2 border-[#a03030] relative overflow-hidden -rotate-12">
<div class="absolute inset-0 bg-[#f0c0a8] rounded-full scale-x-90 translate-y-1"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[#a03030] opacity-30 text-xs"></div>
</div>
<div class="w-12 h-20 bg-[#d95763] rounded-full border-2 border-[#a03030] relative overflow-hidden rotate-12">
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[#a03030] opacity-30 text-xs"></div>
</div>
</template>
<template v-else-if="result === 'Smile'">
<div class="w-12 h-20 bg-[#d95763] rounded-full border-2 border-[#a03030] relative overflow-hidden -rotate-12">
<div class="absolute inset-0 bg-[#f0c0a8] rounded-full scale-x-90 translate-y-1"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[#a03030] opacity-30 text-xs"></div>
</div>
<div class="w-12 h-20 bg-[#d95763] rounded-full border-2 border-[#a03030] relative overflow-hidden rotate-12">
<div class="absolute inset-0 bg-[#f0c0a8] rounded-full scale-x-90 translate-y-1"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[#a03030] opacity-30 text-xs"></div>
</div>
</template>
<template v-else-if="result === 'Cry'">
<div class="w-12 h-20 bg-[#d95763] rounded-full border-2 border-[#a03030] relative overflow-hidden -rotate-12">
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[#a03030] opacity-30 text-xs"></div>
</div>
<div class="w-12 h-20 bg-[#d95763] rounded-full border-2 border-[#a03030] relative overflow-hidden rotate-12">
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[#a03030] opacity-30 text-xs"></div>
</div>
</template>
<template v-else>
<!-- Initial or Tossing state (Round side up) -->
<div class="w-12 h-20 bg-[#d95763] rounded-full border-2 border-[#a03030] relative overflow-hidden">
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[#a03030] opacity-30 text-xs"></div>
</div>
<div class="w-12 h-20 bg-[#d95763] rounded-full border-2 border-[#a03030] relative overflow-hidden">
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[#a03030] opacity-30 text-xs"></div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { JiaobeiResult } from '~/types/pixel';
interface Props {
result: JiaobeiResult | null;
isTossing: boolean;
}
defineProps<Props>();
</script>

View File

@ -0,0 +1,80 @@
<template>
<div class="w-12 h-12 relative image-pixelated">
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" shapeRendering="crispEdges">
<!-- Body/Head -->
<rect x="6" y="2" width="4" height="4" :fill="finalSkin" /> <!-- Head -->
<rect x="5" y="6" width="6" height="5" :fill="finalOutfit" /> <!-- Body -->
<rect x="5" y="6" width="2" height="3" :fill="finalOutfit" filter="brightness(0.9)" /> <!-- Left Arm -->
<rect x="9" y="6" width="2" height="3" :fill="finalOutfit" filter="brightness(0.9)" /> <!-- Right Arm -->
<rect x="6" y="11" width="1" height="3" :fill="finalSkin" /> <!-- Leg L -->
<rect x="9" y="11" width="1" height="3" :fill="finalSkin" /> <!-- Leg R -->
<!-- Hair -->
<rect x="5" y="1" width="6" height="2" :fill="finalHair" />
<rect x="4" y="2" width="1" height="3" :fill="finalHair" />
<rect x="11" y="2" width="1" height="3" :fill="finalHair" />
<!-- Face -->
<rect x="7" y="4" width="1" height="1" fill="#000" opacity="0.6"/> <!-- Eye -->
<rect x="9" y="4" width="1" height="1" fill="#000" opacity="0.6"/> <!-- Eye -->
<!-- Accessory/Deity Specifics -->
<rect v-if="deityId === 'Mazu'" x="5" y="0" width="6" height="1" fill="#d4af37" /> <!-- Crown -->
<rect v-if="deityId === 'EarthGod'" x="5" y="10" width="6" height="1" fill="#8e5c2e" /> <!-- Belt -->
<rect v-if="deityId === 'Matchmaker'" x="10" y="7" width="2" height="2" fill="#ff0000" /> <!-- Red Thread -->
<!-- Weapon -->
<path v-if="weapon === 'sword'" d="M11 9 L13 7 L14 8 L12 10 Z" fill="#ccc" />
<rect v-if="weapon === 'staff'" x="11" y="5" width="1" height="8" fill="#8d6e63" />
</svg>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
skinColor?: string;
hairColor?: string;
outfitColor?: string;
weapon?: 'none' | 'sword' | 'staff';
deityId?: string;
}
const props = withDefaults(defineProps<Props>(), {
skinColor: '#ffdbac',
hairColor: '#5e412f',
outfitColor: '#78909c',
weapon: 'none'
});
const finalSkin = computed(() => {
if (props.deityId === 'Mazu') return '#ffe0bd';
if (props.deityId === 'EarthGod') return '#f0c0a8';
if (props.deityId === 'Matchmaker') return '#ffe0bd';
if (props.deityId === 'Wenchang') return '#ffe0bd';
return props.skinColor;
});
const finalHair = computed(() => {
if (props.deityId === 'Mazu') return '#1a1a1a';
if (props.deityId === 'EarthGod') return '#f0f0f0';
if (props.deityId === 'Matchmaker') return '#f0f0f0';
if (props.deityId === 'Wenchang') return '#1a1a1a';
return props.hairColor;
});
const finalOutfit = computed(() => {
if (props.deityId === 'Mazu') return '#ffa500';
if (props.deityId === 'EarthGod') return '#d75b5b';
if (props.deityId === 'Matchmaker') return '#d95763';
if (props.deityId === 'Wenchang') return '#9fd75b';
return props.outfitColor;
});
</script>
<style scoped>
.image-pixelated {
image-rendering: pixelated;
}
</style>

View File

@ -0,0 +1,35 @@
<template>
<button
:class="[baseStyles, variantStyles, className]"
v-bind="$attrs"
>
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
variant?: 'primary' | 'secondary' | 'danger';
className?: string;
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
className: ''
});
const baseStyles = "relative px-4 py-2 text-xs font-bold uppercase tracking-wider border-2 transition-transform active:translate-y-1";
const variantStyles = computed(() => {
if (props.variant === 'primary') {
return "bg-[#2b193f] border-[#f6b26b] text-[#f6b26b] hover:bg-[#3d2459] hover:text-white";
} else if (props.variant === 'secondary') {
return "bg-[#1b1026] border-[#4a3b5e] text-[#8f80a0] hover:bg-[#2b193f] hover:text-[#e0d8f0]";
} else if (props.variant === 'danger') {
return "bg-[#2b193f] border-[#d95763] text-[#d95763] hover:bg-[#3d2459] hover:text-white";
}
return "";
});
</script>

View File

@ -0,0 +1,40 @@
<template>
<div
class="relative flex flex-col border-2"
:class="className"
:style="{ borderColor: borderColor, backgroundColor: bgColor }"
>
<div v-if="title" class="absolute -top-3 left-2 bg-[#1b1026] px-1 z-10">
<span class="text-[10px] font-bold tracking-widest uppercase text-[#8f80a0]">{{ title }}</span>
</div>
<div class="flex-grow">
<slot />
</div>
<!-- Corner Pixels for decoration -->
<div class="absolute -top-1 -left-1 w-1 h-1 bg-[#1b1026]" />
<div class="absolute -top-1 -right-1 w-1 h-1 bg-[#1b1026]" />
<div class="absolute -bottom-1 -left-1 w-1 h-1 bg-[#1b1026]" />
<div class="absolute -bottom-1 -right-1 w-1 h-1 bg-[#1b1026]" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
title?: string;
className?: string;
variant?: 'default' | 'inset';
highlight?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
className: '',
variant: 'default',
highlight: false
});
const borderColor = computed(() => props.highlight ? '#f6b26b' : '#4a3b5e');
const bgColor = computed(() => props.variant === 'inset' ? '#0f0816' : '#1b1026');
</script>

View File

@ -0,0 +1,46 @@
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div class="w-full max-w-2xl max-h-[80vh] flex flex-col bg-[#1b1026] border-4 border-[#4a3b5e] shadow-[0_0_20px_rgba(0,0,0,0.5)] relative animate-in fade-in zoom-in duration-200">
<!-- Header -->
<div class="flex items-center justify-between p-2 border-b-2 border-[#4a3b5e] bg-[#231533]">
<h2 class="text-[#f6b26b] font-bold tracking-[0.2em] ml-2 text-sm md:text-base">{{ title }}</h2>
<button @click="$emit('close')" class="p-1 hover:bg-[#d95763] hover:text-white text-[#8f80a0] transition-colors">
<X :size="18" />
</button>
</div>
<!-- Content -->
<div class="flex-grow overflow-y-auto p-4 custom-scrollbar text-[#e0d8f0]">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { X } from 'lucide-vue-next';
interface Props {
isOpen: boolean;
title: string;
}
defineProps<Props>();
defineEmits(['close']);
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #0f0816;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #4a3b5e;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #f6b26b;
}
</style>

View File

@ -0,0 +1,111 @@
<template>
<div class="h-full flex flex-col p-4 gap-4 bg-[#1b1026] overflow-y-auto custom-scrollbar">
<!-- Pet Avatar -->Portrait & Basic Info -->
<PixelFrame class="flex-shrink-0" title="PET INFO">
<!-- Helper Buttons Overlay -->
<div class="absolute top-1 right-1 z-30">
<button
@click="$emit('openAchievements')"
class="p-1 bg-[#2b193f] border border-[#f6b26b] hover:bg-[#3d2459] active:translate-y-0.5 group"
title="Achievements"
>
<Trophy :size="14" class="text-[#f6b26b] group-hover:text-white" />
</button>
</div>
<div class="flex flex-col items-center p-1 relative">
<div class="w-20 h-20 bg-[#1b1026] border-4 border-[#4a3b5e] mb-2 relative overflow-hidden group shadow-inner flex items-center justify-center">
<!-- Background for portrait -->
<div class="absolute inset-0 bg-[#2b193f] opacity-50" />
<!-- The Animated Pixel Avatar -->
<div class="scale-110 transform translate-y-1">
<PixelAvatar
skinColor="#ffdbac"
hairColor="#e0d8f0"
outfitColor="#9fd75b"
/>
</div>
<!-- Scanline on portrait -->
<div class="absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0)_50%,rgba(0,0,0,0.2)_50%)] bg-[length:100%_4px] pointer-events-none z-20" />
</div>
<h2 class="text-xl text-[#f6b26b] tracking-[0.2em] font-bold border-b-2 border-[#f6b26b] mb-1 leading-none pb-1">{{ stats.name }}</h2>
<span class="text-xs text-[#8f80a0] uppercase tracking-wide">{{ stats.class }}</span>
</div>
</PixelFrame>
<!-- Vitals - Updated to Health, Hunger, Happiness -->
<div class="flex flex-col gap-1 px-1">
<RetroResourceBar :current="stats.hp" :max="stats.maxHp" type="hp" label="Health" :icon="Heart" />
<RetroResourceBar v-if="stats.hunger !== undefined" :current="stats.hunger" :max="stats.maxHunger || 100" type="energy" label="Hunger" :icon="Drumstick" />
<RetroResourceBar v-if="stats.happiness !== undefined" :current="stats.happiness" :max="stats.maxHappiness || 100" type="mana" label="Happy" :icon="Smile" />
</div>
<!-- Pet Details Grid -->
<PixelFrame class="flex-shrink-0 mt-1" variant="inset">
<div class="grid grid-cols-2 gap-x-2 gap-y-2 text-[10px] uppercase text-[#8f80a0]">
<div class="flex flex-col border-r border-[#4a3b5e] pr-1">
<span class="text-[#4a3b5e]">Age</span>
<span class="text-[#e0d8f0] font-mono tracking-wide">{{ stats.age }}</span>
</div>
<div class="flex flex-col pl-1">
<span class="text-[#4a3b5e]">Gen</span>
<span class="text-[#e0d8f0] font-mono tracking-wide">{{ stats.generation }}</span>
</div>
<div class="flex items-center gap-1 border-t border-[#4a3b5e] pt-1 col-span-2">
<Ruler :size="10" />
<span class="text-[#e0d8f0]">{{ stats.height }}</span>
<span class="text-[#4a3b5e] mx-1">|</span>
<Scale :size="10" />
<span class="text-[#e0d8f0]">{{ stats.weight }}</span>
</div>
</div>
</PixelFrame>
<!-- Fate & God Favor -->
<div class="flex flex-col gap-2 mt-2 px-1">
<!-- Fate -->
<div v-if="stats.fate" class="flex items-center gap-2 bg-[#2b193f] p-1 border border-[#4a3b5e] rounded">
<Leaf :size="12" color="#99e550" />
<div class="flex flex-col leading-none">
<span class="text-[8px] text-[#8f80a0] uppercase">Fate</span>
<span class="text-[10px] text-[#e0d8f0] tracking-wide">{{ stats.fate }}</span>
</div>
</div>
<!-- God Favor -->
<div v-if="stats.godFavor" class="flex flex-col gap-1">
<div class="flex justify-between text-[10px] text-[#8f80a0] uppercase">
<span>Favor: {{ stats.godFavor.name }}</span>
<span>{{ stats.godFavor.current }}/{{ stats.godFavor.max }}</span>
</div>
<div class="h-2 bg-[#150c1f] border border-[#4a3b5e] rounded-full overflow-hidden">
<div :style="{ width: `${(stats.godFavor.current / stats.godFavor.max) * 100}%` }" class="h-full bg-[#f6b26b]" />
</div>
</div>
</div>
<!-- Gold -->
<div class="mt-auto px-1 pb-1">
<RetroCounter :icon="Coins" :value="stats.gold || 0" color="#ffe762" />
</div>
</div>
</template>
<script setup lang="ts">
import { Ruler, Scale, Heart, Smile, Drumstick, Coins, Leaf, Trophy } from 'lucide-vue-next';
import PixelFrame from './PixelFrame.vue';
import RetroResourceBar from './RetroResourceBar.vue';
import RetroCounter from './RetroCounter.vue';
import PixelAvatar from './PixelAvatar.vue';
import type { EntityStats } from '~/types/pixel';
interface Props {
stats: EntityStats;
}
defineProps<Props>();
defineEmits(['openAchievements']);
</script>

View File

@ -0,0 +1,24 @@
<template>
<div class="flex items-center gap-2 bg-[#0f0816] border border-[#4a3b5e] px-2 py-1 rounded-sm">
<component :is="icon" :size="14" :style="{ color: color }" />
<div class="flex flex-col leading-none">
<span v-if="label" class="text-[8px] text-[#8f80a0] uppercase">{{ label }}</span>
<span class="font-mono text-sm font-bold text-[#e0d8f0]">{{ value }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue';
interface Props {
icon: Component;
value: number | string;
label?: string;
color?: string;
}
withDefaults(defineProps<Props>(), {
color: '#e0d8f0'
});
</script>

View File

@ -0,0 +1,19 @@
<template>
<div class="h-2 w-full bg-[#0f0816] border border-[#4a3b5e]">
<div
class="h-full transition-all duration-500"
:style="{ width: `${Math.min(100, Math.max(0, progress))}%`, backgroundColor: color }"
/>
</div>
</template>
<script setup lang="ts">
interface Props {
progress: number;
color?: string;
}
withDefaults(defineProps<Props>(), {
color: '#9fd75b'
});
</script>

View File

@ -0,0 +1,43 @@
<template>
<div class="flex flex-col gap-0.5 w-full">
<div v-if="label" class="flex justify-between items-end px-0.5">
<div class="flex items-center gap-1 text-[#8f80a0]">
<component :is="icon" v-if="icon" :size="10" />
<span class="text-[10px] uppercase font-bold leading-none">{{ label }}</span>
</div>
<span class="text-[10px] font-mono text-[#e0d8f0] leading-none">{{ current }}/{{ max }}</span>
</div>
<div class="h-3 bg-[#0f0816] border border-[#4a3b5e] p-[1px] relative">
<div
class="h-full transition-all duration-300 relative"
:style="{ width: `${percentage}%`, backgroundColor: barColor }"
>
<!-- Shine effect -->
<div class="absolute top-0 left-0 w-full h-[1px] bg-white opacity-30" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
interface Props {
current: number;
max: number;
type: 'hp' | 'energy' | 'mana';
label?: string;
icon?: Component;
}
const props = defineProps<Props>();
const percentage = computed(() => Math.min(100, Math.max(0, (props.current / props.max) * 100)));
const barColor = computed(() => {
if (props.type === 'energy') return '#f6b26b'; // Orange
if (props.type === 'mana') return '#2ce8f4'; // Cyan
return '#d95763'; // HP Red (default)
});
</script>

View File

@ -0,0 +1,145 @@
<template>
<div class="flex flex-col h-full gap-2 relative">
<!-- Top Bar: Gold & Title -->
<div class="flex items-center justify-between bg-[#1b1026] p-2 border border-[#f6b26b]">
<div class="flex items-center gap-2 text-[#9fd75b] font-bold tracking-widest">
<ShoppingBag :size="20" />
<span>商店 (SHOP)</span>
</div>
<div class="flex items-center gap-2 bg-[#2b193f] px-3 py-1 rounded border border-[#4a3b5e]">
<span class="text-[#99e550] text-sm uppercase">您的金幣:</span>
<span class="text-[#f6b26b] font-mono text-lg font-bold">{{ playerGold }}</span>
<Coins :size="16" class="text-[#f6b26b]" />
<div class="border-l border-[#4a3b5e] pl-2 ml-1">
<RefreshCw :size="14" class="text-[#8f80a0] cursor-pointer hover:text-white hover:rotate-180 transition-transform" />
</div>
</div>
</div>
<!-- Main Action Tabs -->
<div class="flex gap-4 justify-center my-2">
<PixelButton
:variant="mode === 'BUY' ? 'primary' : 'secondary'"
@click="mode = 'BUY'"
class="w-32"
:class="{ 'bg-[#3d9e8f] border-[#2c7a6f]': mode === 'BUY' }"
:style="mode === 'BUY' ? { backgroundColor: '#3d9e8f', borderColor: '#2c7a6f' } : {}"
>
購買 (BUY)
</PixelButton>
<PixelButton
:variant="mode === 'SELL' ? 'primary' : 'secondary'"
@click="mode = 'SELL'"
class="w-32"
:style="mode === 'SELL' ? { backgroundColor: '#d95763', borderColor: '#ac3232', color: 'white' } : {}"
>
賣出 (SELL)
</PixelButton>
</div>
<!-- Category Filters -->
<div class="flex gap-1 overflow-x-auto pb-2 custom-scrollbar">
<button
v-for="cat in CATEGORY_FILTERS"
:key="cat.id"
@click="filter = cat.id"
class="flex items-center gap-1 px-3 py-1 text-xs border whitespace-nowrap transition-colors"
:class="filter === cat.id ? 'bg-[#9fd75b] text-[#1b1026] border-[#f6b26b]' : 'bg-[#150c1f] text-[#8f80a0] border-[#4a3b5e] hover:bg-[#2b193f]'"
>
<component :is="cat.icon" :size="12" />
{{ cat.label }}
</button>
</div>
<!-- Items List -->
<div class="flex-grow bg-[#0f0816] border border-[#2b193f] p-2 overflow-y-auto custom-scrollbar">
<div v-if="displayedItems.length === 0" class="h-full flex items-center justify-center text-[#4a3b5e] flex-col gap-2">
<Search :size="32" />
<span>NO ITEMS FOUND</span>
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in displayedItems"
:key="item.id"
class="flex items-center justify-between p-2 bg-[#1b1026] border border-[#2b193f] hover:border-[#4a3b5e] transition-colors group"
>
<!-- Item Icon & Info -->
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-[#231533] border border-[#4a3b5e] flex items-center justify-center relative">
<!-- Simple Icon Logic -->
<Cookie v-if="item.category === ItemCategory.Food" color="#f6b26b" />
<Pill v-else-if="item.category === ItemCategory.Medicine" color="#d95763" />
<Sword v-else-if="item.category === ItemCategory.Equipment" color="#2ce8f4" />
<Gamepad2 v-else-if="item.category === ItemCategory.Toy" color="#99e550" />
<Gem v-else-if="item.category === ItemCategory.Accessory" color="#d584fb" />
<span v-if="item.quantity && item.quantity > 1" class="absolute bottom-0 right-0 bg-black text-white text-[9px] px-1">{{ item.quantity }}</span>
</div>
<div class="flex flex-col">
<span class="font-bold text-sm tracking-wide" :class="item.rarity === Rarity.Legendary ? 'text-[#ffa500]' : 'text-[#9fd75b]'">
{{ item.name }}
</span>
<div class="flex items-center gap-2">
<span class="text-[10px] text-[#f6b26b] font-mono">
$ {{ mode === 'SELL' ? Math.floor(item.price / 2) : item.price }}
</span>
<span v-if="item.quantity" class="text-[10px] text-[#8f80a0]">x {{ item.quantity }}</span>
</div>
</div>
</div>
<!-- Action Button -->
<button
@click="mode === 'BUY' ? $emit('buy', item) : $emit('sell', item)"
class="px-4 py-1 border-2 text-xs font-bold tracking-widest active:translate-y-0.5"
:class="mode === 'BUY'
? 'bg-[#1b1026] border-[#9fd75b] text-[#9fd75b] hover:bg-[#9fd75b] hover:text-[#1b1026]'
: 'bg-[#1b1026] border-[#d95763] text-[#d95763] hover:bg-[#d95763] hover:text-[#1b1026]'"
>
{{ mode === 'BUY' ? '購買' : '賣出' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ShoppingBag, Coins, Filter, Cookie, Pill, Sword, Gamepad2, Gem, Search, RefreshCw } from 'lucide-vue-next';
import PixelButton from './PixelButton.vue';
import { ItemCategory, Rarity } from '~/types/pixel';
import type { Item } from '~/types/pixel';
interface Props {
playerGold: number;
inventory: Item[];
shopItems: Item[];
}
const props = defineProps<Props>();
defineEmits(['buy', 'sell']);
const mode = ref<'BUY' | 'SELL'>('BUY');
const filter = ref<string>('ALL');
const CATEGORY_FILTERS = [
{ id: 'ALL', label: '全部 (ALL)', icon: Search },
{ id: ItemCategory.Food, label: '食物', icon: Cookie },
{ id: ItemCategory.Medicine, label: '藥品', icon: Pill },
{ id: ItemCategory.Equipment, label: '裝備', icon: Sword },
{ id: ItemCategory.Toy, label: '玩具', icon: Gamepad2 },
{ id: ItemCategory.Accessory, label: '飾品', icon: Gem },
];
const displayedItems = computed(() => {
const source = mode.value === 'BUY' ? props.shopItems : props.inventory;
return source.filter(item => {
if (mode.value === 'SELL' && item.isEquipped) return false; // Cannot sell equipped items
if (filter.value === 'ALL') return true;
return item.category === filter.value;
});
});
</script>

View File

@ -1,458 +1,435 @@
<template>
<div class="cute-game-container">
<!-- 左上角 HUD -->
<PetHUD
:pet-name="petState.name || 'Pet'"
:health="petState.health || 0"
:max-health="getMaxHealth()"
:hunger="petState.hunger || 0"
:happiness="petState.happiness || 0"
:pet-emotion="petEmotion"
/>
<!-- 主場景 -->
<div class="main-scene">
<ScrollableScene
:pet-emotion="petEmotion"
@interact="handleSceneInteract"
/>
</div>
<!-- 底部快捷欄 -->
<div class="bottom-toolbar">
<button class="tool-btn" @click="handleFeed" title="餵食">
🍖
</button>
<button class="tool-btn" @click="handlePlay" title="玩耍">
🎾
</button>
<button class="tool-btn" @click="handleClean" title="清理">
🧹
</button>
<button class="tool-btn" @click="handleHeal" title="治療">
💊
</button>
<button class="tool-btn" @click="handleSleep" title="睡覺">
{{ petState.isSleeping ? '⏰' : '💤' }}
</button>
<button class="tool-btn inventory-btn" @click="showInventory = true" title="背包">
🎒
</button>
<button class="tool-btn" @click="showStats = !showStats" title="狀態">
📊
</button>
</div>
<!-- 背包界面 -->
<InventoryModal
:is-open="showInventory"
:coins="petState.coins || 0"
:attack="petState.attack || 10"
:defense="petState.defense || 5"
@close="showInventory = false"
@use-item="handleUseItem"
@drop-item="handleDropItem"
/>
<!-- 狀態面板可選顯示 -->
<div v-if="showStats" class="stats-overlay" @click="showStats = false">
<div class="stats-panel" @click.stop>
<h3>📊 寵物狀態</h3>
<div class="stat-list">
<div class="stat-item">
<span>名稱:</span>
<span>{{ petState.name || '寵物' }}</span>
</div>
<div class="stat-item">
<span>階段:</span>
<span>{{ petState.stage || '蛋' }}</span>
</div>
<div class="stat-item">
<span>年齡:</span>
<span>{{ formatAge(petState.ageSeconds) }}</span>
</div>
<div class="stat-item">
<span>力量:</span>
<span>{{ petState.str || 0 }}</span>
</div>
<div class="stat-item">
<span>智力:</span>
<span>{{ petState.int || 0 }}</span>
</div>
<div class="stat-item">
<span>敏捷:</span>
<span>{{ petState.dex || 0 }}</span>
</div>
<div class="stat-item">
<span>運勢:</span>
<span>{{ petState.luck || 0 }}</span>
</div>
<div class="w-full min-h-screen bg-[#1b1026] flex items-center justify-center p-2 md:p-4 lg:p-8 font-sans">
<!-- Main Container -->
<div class="w-full max-w-7xl bg-[#0f0816] border-4 md:border-6 border-[#2b193f] relative shadow-2xl flex flex-col md:flex-row overflow-hidden rounded-lg"
:class="{'aspect-video': isDesktop, 'min-h-screen': !isDesktop}">
<!-- Left Column: Player Panel -->
<div class="w-full md:w-1/3 lg:w-1/4 h-auto md:h-full border-b-4 md:border-b-0 md:border-r-4 border-[#2b193f] bg-[#1b1026] z-20">
<PlayerPanel
v-if="initialized"
:stats="playerStats"
@openAchievements="showAchievements = true"
/>
<div v-else class="flex items-center justify-center h-32 md:h-full text-[#8f80a0] text-xs">
Initializing...
</div>
<button class="close-stats-btn" @click="showStats = false">關閉</button>
</div>
<!-- Middle Column: Room + Actions -->
<div class="w-full md:w-1/3 lg:w-1/2 h-auto md:h-full flex flex-col relative z-10">
<!-- Top: Battle/Room Area -->
<div class="h-64 md:h-[55%] border-b-4 border-[#2b193f] relative bg-[#0f0816]">
<BattleArea
v-if="initialized"
:currentDeityId="currentDeity"
:isFighting="isFighting"
:battleLogs="battleLogs"
/>
</div>
<!-- Bottom: Action Area -->
<div class="h-auto md:h-[45%] bg-[#1b1026]">
<ActionArea
v-if="initialized"
:playerStats="playerStats"
@openInventory="showInventory = true"
@openGodSystem="showGodSystem = true"
@openShop="showShop = true"
@openAdventure="showAdventureSelect = true"
/>
</div>
</div>
<!-- Right Column: Deity Panel (Info Panel) -->
<div class="w-full md:w-1/3 lg:w-1/4 h-auto md:h-full border-t-4 md:border-t-0 md:border-l-4 border-[#2b193f] bg-[#1b1026] z-20">
<InfoPanel v-if="deities[currentDeity]" :deity="deities[currentDeity]" />
<div v-else class="flex items-center justify-center h-32 md:h-full text-[#8f80a0] text-xs">
Loading...
</div>
</div>
</div>
<!-- 載入畫面 -->
<div v-if="!initialized" class="loading-overlay">
<div class="loading-content">
<div class="loading-spinner"></div>
<p>載入中...</p>
</div>
</div>
<!-- --- MODALS --- -->
<!-- Achievements Overlay -->
<PixelModal
:isOpen="showAchievements"
@close="showAchievements = false"
title="ACHIEVEMENTS"
>
<AchievementsOverlay :achievements="ACHIEVEMENTS_DATA" />
</PixelModal>
<!-- Inventory Overlay -->
<PixelModal
:isOpen="showInventory"
@close="showInventory = false"
title="INVENTORY"
>
<InventoryOverlay
:items="inventory"
@equip="handleEquip"
@unequip="handleUnequip"
@use="handleUseItem"
@delete="handleDeleteItem"
/>
</PixelModal>
<!-- God System Overlay -->
<PixelModal
:isOpen="showGodSystem"
@close="showGodSystem = false"
title="GOD SYSTEM"
>
<GodSystemOverlay
:currentDeity="currentDeity"
:deities="deities"
@switchDeity="handleSwitchDeity"
@addFavor="handleAddFavor"
/>
</PixelModal>
<!-- Shop Overlay -->
<PixelModal
:isOpen="showShop"
@close="showShop = false"
title="SHOP"
>
<ShopOverlay
:playerGold="playerStats.gold || 0"
:inventory="inventory"
:shopItems="SHOP_ITEMS"
@buy="handleBuyItem"
@sell="handleSellItem"
/>
</PixelModal>
<!-- Adventure Selection Overlay -->
<PixelModal
:isOpen="showAdventureSelect"
@close="showAdventureSelect = false"
title="ADVENTURE"
>
<AdventureOverlay
:locations="ADVENTURE_LOCATIONS"
:playerStats="playerStats"
@selectLocation="handleStartAdventure"
@close="showAdventureSelect = false"
/>
</PixelModal>
<!-- Battle Result Modal (Custom Styling Modal) -->
<div v-if="showBattleResult" class="fixed inset-0 z-[110] flex items-center justify-center bg-black/80">
<div class="w-[500px] border-4 border-[#2ce8f4] bg-black p-1 shadow-[0_0_50px_#2ce8f4]">
<div class="border-2 border-[#2ce8f4] p-8 flex flex-col items-center gap-4">
<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>
<button
@click="handleCloseBattleResult"
class="mt-6 border border-[#99e550] text-[#99e550] px-8 py-2 hover:bg-[#99e550] hover:text-black uppercase tracking-widest"
>
確定
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { PetSystem } from '../../core/pet-system.js'
import { apiService } from '../../core/api-service.js'
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { PartyPopper } from 'lucide-vue-next';
import PlayerPanel from '~/components/pixel/PlayerPanel.vue';
import BattleArea from '~/components/pixel/BattleArea.vue';
import ActionArea from '~/components/pixel/ActionArea.vue';
import InfoPanel from '~/components/pixel/InfoPanel.vue';
import PixelModal from '~/components/pixel/PixelModal.vue';
import AchievementsOverlay from '~/components/pixel/AchievementsOverlay.vue';
import InventoryOverlay from '~/components/pixel/InventoryOverlay.vue';
import GodSystemOverlay from '~/components/pixel/GodSystemOverlay.vue';
import ShopOverlay from '~/components/pixel/ShopOverlay.vue';
import AdventureOverlay from '~/components/pixel/AdventureOverlay.vue';
import PetHUD from '../../components/pixel/PetHUD.vue'
import ScrollableScene from '../../components/pixel/ScrollableScene.vue'
import InventoryModal from '../../components/pixel/InventoryModal.vue'
import { PetSystem } from '../../core/pet-system.js';
import { TempleSystem } from '../../core/temple-system.js';
import { ApiService } from '../../core/api-service.js';
const petSystem = ref(null)
const petState = ref({})
const initialized = ref(false)
const showInventory = ref(false)
const showStats = ref(false)
import {
ItemType,
Rarity,
EquipSlot,
DeityId,
ItemCategory
} from '~/types/pixel';
import type {
EntityStats,
Achievement,
Item,
Deity,
AdventureLocation
} from '~/types/pixel';
// --- SYSTEMS INITIALIZATION ---
const apiService = new ApiService({ useMock: true }); // Use mock for now
const petSystem = ref<PetSystem | null>(null);
const templeSystem = ref<TempleSystem | null>(null);
const initialized = ref(false);
// --- RESPONSIVE ---
const isDesktop = ref(true);
// Detect screen size
if (typeof window !== 'undefined') {
const updateScreenSize = () => {
isDesktop.value = window.innerWidth >= 768;
};
updateScreenSize();
window.addEventListener('resize', updateScreenSize);
onUnmounted(() => {
window.removeEventListener('resize', updateScreenSize);
});
}
// --- STATE ---
// Reactive state mapped from PetSystem
const systemState = ref<any>(null);
const allDeities = ref<Deity[]>([]);
const playerStats = computed<EntityStats>(() => {
if (!systemState.value) return {
name: "Loading...", class: "Egg", hp: 100, maxHp: 100, sp: 0, maxSp: 0, lvl: 1,
hunger: 100, maxHunger: 100, happiness: 100, maxHappiness: 100,
age: "0d 0h", generation: 1, height: "0 cm", weight: "0 g", gold: 0, fate: "Unknown",
godFavor: { name: "None", current: 0, max: 100 },
str: 0, int: 0, dex: 0, luck: 0, atk: 0, def: 0, spd: 0
};
const s = systemState.value;
const currentDeity = allDeities.value.find(d => d.id === s.currentDeityId);
return {
name: "Pet",
class: s.stage,
hp: Math.floor(s.health),
maxHp: 100,
sp: 0,
maxSp: 100,
lvl: 1,
hunger: Math.floor(s.hunger),
maxHunger: 100,
happiness: Math.floor(s.happiness),
maxHappiness: 100,
age: formatAge(s.ageSeconds),
generation: s.generation || 1,
height: `${s.height || 0} cm`,
weight: `${Math.floor(s.weight || 0)} g`,
gold: s.coins || 0,
fate: s.destiny?.name || "None",
godFavor: {
name: currentDeity?.name || "None",
current: s.deityFavors?.[s.currentDeityId] || 0,
max: 100
},
str: Math.floor(s.effectiveStr || s.str),
int: Math.floor(s.effectiveInt || s.int),
dex: Math.floor(s.effectiveDex || s.dex),
luck: Math.floor(s.effectiveLuck || s.luck),
atk: Math.floor(s.attack || 0),
def: Math.floor(s.defense || 0),
spd: Math.floor(s.speed || 0)
};
});
const inventory = computed<Item[]>(() => {
if (!systemState.value || !systemState.value.inventory) return [];
return systemState.value.inventory.map((i: any) => ({
...i,
icon: i.icon || 'circle',
statsDescription: i.description
}));
});
const deities = computed<Record<DeityId, Deity>>(() => {
const map: Record<string, Deity> = {};
allDeities.value.forEach(d => {
const favor = systemState.value?.deityFavors?.[d.id] || 0;
map[d.id] = { ...d, favor, maxFavor: 100 };
});
return map;
});
const currentDeity = computed(() => systemState.value?.currentDeityId || DeityId.Mazu);
// Modal States
const showAchievements = ref(false);
const showInventory = ref(false);
const showGodSystem = ref(false);
const showShop = ref(false);
const showAdventureSelect = ref(false);
const showBattleResult = ref(false);
// Battle State
const isFighting = ref(false);
const battleLogs = ref<string[]>([]);
// --- LIFECYCLE ---
onMounted(async () => {
try {
petSystem.value = new PetSystem(apiService)
await petSystem.value.initialize()
petSystem.value = new PetSystem(apiService);
templeSystem.value = new TempleSystem(petSystem.value, apiService);
await petSystem.value.initialize();
await templeSystem.value.initialize();
systemState.value = petSystem.value.getState();
allDeities.value = templeSystem.value.getDeities();
petSystem.value.startTickLoop((newState) => {
systemState.value = newState;
});
const state = await petSystem.value.getState()
if (!state.name || state.name.trim() === '') {
await petSystem.value.updateState({ name: '我的寵物' })
initialized.value = true;
});
onUnmounted(() => {
if (petSystem.value) {
petSystem.value.stopTickLoop();
}
updatePetState()
initialized.value = true
});
setInterval(() => {
updatePetState()
}, 1000)
} catch (error) {
console.error('[Index] Failed to initialize:', error)
initialized.value = true
petState.value = {
name: '錯誤',
stage: 'egg',
hunger: 0,
happiness: 0,
health: 0,
isSleeping: false,
isSick: false,
isDead: false
// --- HELPERS ---
const formatAge = (seconds: number) => {
if (!seconds) return '0h';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
if (days > 0) return `${days}d ${hours}h`;
return `${hours}h`;
};
// --- HANDLERS ---
const handleStartAdventure = (location: AdventureLocation) => {
showAdventureSelect.value = false;
if (petSystem.value) {
petSystem.value.updateState({
hunger: Math.max(0, systemState.value.hunger - location.costHunger),
coins: Math.max(0, systemState.value.coins - location.costGold)
});
}
}
})
const updatePetState = () => {
if (petSystem.value) {
petState.value = { ...petSystem.value.getState() }
}
}
isFighting.value = true;
battleLogs.value = [`Entered ${location.name}...`, `Encountered ${location.enemyName}!`];
const getMaxHealth = () => {
if (!petSystem.value) return 100
const state = petSystem.value.getState()
return 100 + (state.achievementBuffs?.health || 0)
}
let turn = 1;
const interval = setInterval(() => {
if (turn > 5) {
clearInterval(interval);
battleLogs.value.push("Victory!", "Obtained 10 EXP!");
setTimeout(() => {
isFighting.value = false;
showBattleResult.value = true;
}, 1500);
return;
}
const petEmotion = computed(() => {
const state = petState.value
if (state.isSick) return 'sick'
if (state.health < 30) return 'sad'
if (state.happiness < 30) return 'sad'
if (state.hunger < 20) return 'sad'
return 'happy'
})
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.`);
}
turn++;
}, 1000);
};
const formatAge = (seconds) => {
if (!seconds) return '0h'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
const handleCloseBattleResult = () => {
showBattleResult.value = false;
battleLogs.value = [];
};
const handleFeed = async () => {
if (petSystem.value) {
await petSystem.value.feed()
updatePetState()
}
}
const handleEquip = async (itemId: string, asAppearance: boolean) => {
console.log("Equip not fully implemented in core yet", itemId);
};
const handlePlay = async () => {
if (petSystem.value) {
await petSystem.value.play()
updatePetState()
}
}
const handleUnequip = async (slot: EquipSlot, asAppearance: boolean) => {
console.log("Unequip not fully implemented in core yet", slot);
};
const handleClean = async () => {
if (petSystem.value) {
await petSystem.value.cleanPoop()
updatePetState()
}
}
const handleUseItem = async (itemId: string) => {
console.log("Use item not fully implemented in core yet", itemId);
};
const handleHeal = async () => {
if (petSystem.value) {
await petSystem.value.heal()
updatePetState()
}
}
const handleDeleteItem = async (itemId: string) => {
console.log("Delete item not fully implemented in core yet", itemId);
};
const handleSleep = async () => {
if (petSystem.value) {
await petSystem.value.toggleSleep()
updatePetState()
}
}
const handleSwitchDeity = async (id: DeityId) => {
if (templeSystem.value) {
await templeSystem.value.switchDeity(id);
systemState.value = petSystem.value?.getState();
}
};
const handleSceneInteract = (objectType) => {
console.log('[Index] Interacted with:', objectType)
if (objectType === 'food') handleFeed()
else if (objectType === 'toy') handlePlay()
else if (objectType === 'bed') handleSleep()
}
const handleAddFavor = async (amount: number) => {
if (templeSystem.value) {
await templeSystem.value.pray();
systemState.value = petSystem.value?.getState();
}
};
const handleUseItem = (item) => {
console.log('[Index] Use item:', item)
}
const handleBuyItem = async (item: Item) => {
if (petSystem.value && systemState.value.coins >= item.price) {
const newCoins = systemState.value.coins - item.price;
const newInventory = [...(systemState.value.inventory || []), { ...item, id: `buy-${Date.now()}` }];
await petSystem.value.updateState({ coins: newCoins, inventory: newInventory });
systemState.value = petSystem.value.getState();
} else {
alert("Not enough gold!");
}
};
const handleDropItem = (item) => {
console.log('[Index] Drop item:', item)
}
const handleSellItem = async (item: Item) => {
if (petSystem.value) {
const sellPrice = Math.floor(item.price / 2);
const newCoins = systemState.value.coins + sellPrice;
const newInventory = systemState.value.inventory.filter((i: any) => i.id !== item.id);
await petSystem.value.updateState({ coins: newCoins, inventory: newInventory });
systemState.value = petSystem.value.getState();
}
};
// --- MOCK DATA FOR STATIC CONTENT ---
const ADVENTURE_LOCATIONS: AdventureLocation[] = [
{ id: '1', name: '自家後院', description: '安全的新手探險地,偶爾會有小蟲子。', costHunger: 5, costGold: 5, difficulty: 'Easy', enemyName: '野蟲' },
{ id: '2', name: '附近的公園', description: '熱鬧的公園,但也潛藏著流浪動物的威脅。', costHunger: 15, costGold: 10, reqStats: { str: 20 }, difficulty: 'Medium', enemyName: '流浪貓' },
{ id: '3', name: '神秘森林', description: '危險的未知區域,只有強者才能生存。', costHunger: 30, costGold: 20, reqStats: { str: 50, int: 30 }, difficulty: 'Hard', enemyName: '樹妖' }
];
const ACHIEVEMENTS_DATA: Achievement[] = [
{ id: '1', title: 'First Step', description: 'Pet age reaches 1 hour', reward: 'STR Growth +5% INT Growth +5%', progress: 100, unlocked: true, icon: 'baby', color: '#ffe762' },
{ id: '2', title: 'One Day Plan', description: 'Pet age reaches 1 day', reward: 'STR/INT/DEX Growth +10% LUCK +2', progress: 100, unlocked: true, icon: 'calendar', color: '#ffe762' },
];
const SHOP_ITEMS: Item[] = [
{ id: 's1', name: 'Fortune Cookie', type: ItemType.Consumable, category: ItemCategory.Food, price: 10, rarity: Rarity.Common, description: 'A crisp cookie with a fortune inside.', statsDescription: 'Happiness +5', icon: 'cookie' },
{ id: 's2', name: 'Tuna Can', type: ItemType.Consumable, category: ItemCategory.Food, price: 30, rarity: Rarity.Common, description: 'High quality tuna. Cats love it.', statsDescription: 'Hunger -50', icon: 'fish' },
{ id: 's3', name: 'Premium Food', type: ItemType.Consumable, category: ItemCategory.Food, price: 50, rarity: Rarity.Excellent, description: 'Gourmet pet food.', statsDescription: 'Hunger -100 Happiness +10', icon: 'star' },
{ id: 's4', name: 'Magic Wand', type: ItemType.Equipment, category: ItemCategory.Toy, price: 150, rarity: Rarity.Rare, description: 'A toy wand that sparkles.', statsDescription: 'Happiness Regen', slot: EquipSlot.Weapon, icon: 'wand' },
{ id: 's5', name: 'Ball', type: ItemType.Equipment, category: ItemCategory.Toy, price: 20, rarity: Rarity.Common, description: 'A bouncy ball.', statsDescription: 'Play +10', slot: EquipSlot.Weapon, icon: 'ball' },
{ id: 's6', name: 'Lucky Coin', type: ItemType.Equipment, category: ItemCategory.Accessory, price: 500, rarity: Rarity.Epic, description: 'Increases luck significantly.', statsDescription: 'LCK +10', slot: EquipSlot.Accessory, icon: 'coin' },
{ id: 's7', name: 'Health Elixir', type: ItemType.Consumable, category: ItemCategory.Medicine, price: 100, rarity: Rarity.Rare, description: 'Fully restores health.', statsDescription: 'HP Full', icon: 'potion' },
];
</script>
<style scoped>
.cute-game-container {
width: 100vw;
height: 100vh;
background: var(--color-bg);
position: relative;
overflow: hidden;
font-family: 'Press Start 2P', 'Noto Sans TC', cursive;
touch-action: manipulation; /* 改善觸控體驗 */
}
.main-scene {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(90vw, 900px);
padding: 0 10px;
}
/* 手機直向 */
@media (max-width: 480px) {
.main-scene {
width: 95vw;
top: 45%;
}
}
/* 平板 */
@media (min-width: 481px) and (max-width: 768px) {
.main-scene {
width: 85vw;
}
}
/* 底部工具欄 */
.bottom-toolbar {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
background: var(--color-panel);
padding: 12px 16px;
border: 3px solid var(--color-accent);
border-radius: 16px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.6), inset 0 2px 0 rgba(255, 255, 255, 0.1);
z-index: 100;
max-width: 90vw;
}
.tool-btn {
width: 52px;
height: 52px;
min-width: 44px; /* 觸控友好最小尺寸 */
min-height: 44px;
background: linear-gradient(180deg, var(--color-panel-light) 0%, var(--color-panel) 100%);
border: 2px solid var(--color-accent-dark);
border-radius: 8px;
font-size: 24px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 0 var(--color-border), inset 0 1px 0 rgba(255, 255, 255, 0.2);
position: relative;
-webkit-tap-highlight-color: transparent; /* 移除點擊高亮 */
}
/* 手機直向 */
@media (max-width: 480px) {
.bottom-toolbar {
bottom: 10px;
padding: 10px 12px;
gap: 8px;
max-width: 95vw;
}
.tool-btn {
width: 48px;
height: 48px;
font-size: 22px;
}
}
/* 手機橫向 */
@media (max-width: 768px) and (orientation: landscape) {
.bottom-toolbar {
bottom: 8px;
padding: 8px 12px;
gap: 6px;
}
.tool-btn {
width: 44px;
height: 44px;
font-size: 20px;
}
}
.tool-btn:hover {
background: linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent-dark) 100%);
transform: translateY(-2px);
box-shadow: 0 5px 0 var(--color-border), inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.tool-btn:active {
transform: translateY(1px);
box-shadow: 0 1px 0 var(--color-border), inset 0 1px 0 rgba(0, 0, 0, 0.2);
}
.tool-btn.inventory-btn {
background: linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent-dark) 100%);
border-color: var(--color-accent-dark);
}
/* 狀態面板覆蓋 */
.stats-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.stats-panel {
width: 90%;
max-width: 400px;
background: var(--color-panel);
border: 3px solid var(--color-accent);
border-radius: 12px;
padding: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8), inset 0 2px 0 rgba(255, 255, 255, 0.1);
}
.stats-panel h3 {
margin: 0 0 16px 0;
color: var(--color-text);
text-align: center;
font-size: 14px;
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.5);
}
.stat-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: var(--color-panel-light);
border: 2px solid var(--color-border);
border-radius: 6px;
font-size: 10px;
color: var(--color-text);
}
.close-stats-btn {
width: 100%;
padding: 12px;
background: linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent-dark) 100%);
border: 2px solid var(--color-border);
border-radius: 8px;
color: var(--color-text);
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 3px 0 var(--color-border), inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.close-stats-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 0 var(--color-border), inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
/* 載入畫面 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 60px;
height: 60px;
border: 6px solid var(--color-panel);
border-top: 6px solid var(--color-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-content p {
color: var(--color-text);
font-size: 12px;
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.5);
}
</style>

File diff suppressed because it is too large Load Diff

180
app/types/pixel.ts Normal file
View File

@ -0,0 +1,180 @@
export enum StatType {
HP = 'HP',
SP = 'SP',
ATK = 'ATK',
DEF = 'DEF',
SPD = 'SPD',
LUCK = 'LCK'
}
export interface EntityStats {
hp: number;
maxHp: number;
sp: number; // Re-purposed for 'Hunger' or general resource if needed
maxSp: number;
lvl: number;
name: string;
class: string;
// New Pet Fields
hunger?: number;
maxHunger?: number;
happiness?: number;
maxHappiness?: number;
age?: string;
generation?: number;
height?: string;
weight?: string;
gold?: number;
fate?: string; // e.g. "Resource Recycling Grandma"
godFavor?: {
name: string;
current: number;
max: number;
};
// Detailed Stats
str?: number;
int?: number;
dex?: number;
luck?: number;
atk?: number;
def?: number;
spd?: number;
}
export interface ActionItem {
id: string;
name: string;
iconName: string; // Mapping to Lucide icon
cooldown: number;
cost: number;
}
export interface EquipmentSlot {
slot: string;
item: string;
}
export interface Achievement {
id: string;
title: string;
description: string;
reward?: string;
progress: number; // 0 to 100 percentage
currentValue?: number;
maxValue?: number;
unlocked: boolean;
icon: string; // Helper to map to Lucide icon in component
color?: string;
}
// --- Inventory System Types ---
export enum Rarity {
Common = 'Common',
Excellent = 'Excellent',
Rare = 'Rare',
Epic = 'Epic',
Legendary = 'Legendary'
}
export enum ItemType {
Equipment = 'Equipment',
Consumable = 'Consumable'
}
export enum ItemCategory {
Food = 'Food',
Medicine = 'Medicine',
Equipment = 'Equipment',
Toy = 'Toy',
Accessory = 'Accessory',
Misc = 'Misc'
}
export enum EquipSlot {
Weapon = 'Weapon',
Armor = 'Armor',
Hat = 'Hat',
Accessory = 'Accessory',
Charm = 'Charm',
Special = 'Special'
}
export interface Item {
id: string;
name: string;
type: ItemType;
category?: ItemCategory; // New field for Shop filters
price: number; // New field for Shop
slot?: EquipSlot; // Only for Equipment
rarity: Rarity;
description: string;
statsDescription?: string; // e.g. "DEF +8 MaxHP +10"
effects?: string[];
icon: string;
quantity?: number; // For consumables
// State helpers
isEquipped?: boolean;
isAppearance?: boolean; // If true, it's in the appearance slot
}
// --- God System Types ---
export enum DeityId {
Mazu = 'Mazu', // 媽祖
EarthGod = 'EarthGod', // 土地公
Matchmaker = 'Matchmaker', // 月老
Wenchang = 'Wenchang' // 文昌
}
export enum JiaobeiResult {
Saint = 'Saint', // 聖杯 (One up, one down) - YES
Smile = 'Smile', // 笑杯 (Two flat faces up) - LAUGH/MAYBE
Cry = 'Cry', // 陰杯 (Two round faces up) - NO
None = 'None'
}
export enum LotPhase {
Idle = 'Idle',
Drawing = 'Drawing', // Shaking the cylinder
PendingVerify = 'PendingVerify', // Lot drawn, needs 3 saint cups
Verifying = 'Verifying', // Tossing blocks
Success = 'Success', // Got 3 saint cups
Failed = 'Failed' // Failed mid-way
}
export interface Deity {
id: DeityId;
name: string;
title: string;
description: string;
favor: number;
maxFavor: number;
colors: {
skin: string;
hair: string;
outfit: string;
accessory: string;
};
}
// --- Adventure System Types ---
export interface AdventureLocation {
id: string;
name: string;
description: string;
costHunger: number;
costGold: number;
reqStats?: {
str?: number;
int?: number;
};
difficulty: 'Easy' | 'Medium' | 'Hard';
enemyName: string;
}

View File

@ -1,114 +0,0 @@
<template>
<PixelCard class="action-menu">
<h3 class="panel-title">Actions</h3>
<div class="action-grid">
<PixelButton
size="small"
variant="success"
@click="$emit('feed')"
:disabled="!canFeed"
>
🍖 Feed
</PixelButton>
<PixelButton
size="small"
variant="primary"
@click="$emit('play')"
:disabled="!canPlay"
>
🎮 Play
</PixelButton>
<PixelButton
size="small"
variant="warning"
@click="$emit('clean')"
:disabled="!canClean"
>
🧹 Clean
</PixelButton>
<PixelButton
size="small"
variant="success"
@click="$emit('heal')"
:disabled="!canHeal"
>
💊 Heal
</PixelButton>
<PixelButton
size="small"
variant="primary"
@click="$emit('sleep')"
:disabled="!canSleep"
>
{{ isSleeping ? '⏰ Wake' : '💤 Sleep' }}
</PixelButton>
<PixelButton
size="small"
variant="warning"
@click="$emit('shop')"
>
💰 Shop
</PixelButton>
</div>
</PixelCard>
</template>
<script setup>
import PixelCard from './PixelCard.vue'
import PixelButton from './PixelButton.vue'
const props = defineProps({
canFeed: {
type: Boolean,
default: true
},
canPlay: {
type: Boolean,
default: true
},
canClean: {
type: Boolean,
default: true
},
canHeal: {
type: Boolean,
default: true
},
canSleep: {
type: Boolean,
default: true
},
isSleeping: {
type: Boolean,
default: false
}
})
defineEmits(['feed', 'play', 'clean', 'heal', 'sleep', 'shop'])
</script>
<style scoped>
.action-menu {
width: 100%;
}
.panel-title {
font-size: 14px;
margin-bottom: 16px;
text-align: center;
text-transform: uppercase;
padding-bottom: 8px;
border-bottom: 2px solid #000;
}
.action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
</style>

View File

@ -1,153 +0,0 @@
<template>
<PixelCard class="info-panel">
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
:class="['tab-btn', { active: activeTab === tab.id }]"
@click="activeTab = tab.id"
>
{{ tab.icon }} {{ tab.label }}
</button>
</div>
<div class="tab-content">
<div v-if="activeTab === 'stats'" class="stats-grid">
<div class="stat-item">
<span class="stat-label">STR</span>
<span class="stat-value">{{ petStats.str || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">INT</span>
<span class="stat-value">{{ petStats.int || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">DEX</span>
<span class="stat-value">{{ petStats.dex || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">LUCK</span>
<span class="stat-value">{{ petStats.luck || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">AGE</span>
<span class="stat-value">{{ formatAge(petStats.ageSeconds) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">STAGE</span>
<span class="stat-value">{{ petStats.stage || 'egg' }}</span>
</div>
</div>
<div v-if="activeTab === 'inventory'" class="inventory-content">
<p class="placeholder-text">Inventory system (coming soon)</p>
</div>
<div v-if="activeTab === 'achievements'" class="achievement-content">
<p class="placeholder-text">Achievements (coming soon)</p>
</div>
<div v-if="activeTab === 'temple'" class="temple-content">
<p class="placeholder-text">Temple system (coming soon)</p>
</div>
</div>
</PixelCard>
</template>
<script setup>
import PixelCard from './PixelCard.vue'
const props = defineProps({
petStats: {
type: Object,
default: () => ({})
}
})
const activeTab = ref('stats')
const tabs = [
{ id: 'stats', label: 'Stats', icon: '📊' },
{ id: 'inventory', label: 'Items', icon: '🎒' },
{ id: 'achievements', label: 'Medals', icon: '🏆' },
{ id: 'temple', label: 'Temple', icon: '🙏' }
]
const formatAge = (seconds) => {
if (!seconds) return '0h'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return `${hours}h ${minutes}m`
}
return `${minutes}m`
}
</script>
<style scoped>
.info-panel {
width: 100%;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
border-bottom: 3px solid #000;
padding-bottom: 8px;
}
.tab-btn {
font-family: 'Press Start 2P', cursive;
background: white;
border: 2px solid #000;
padding: 8px 12px;
cursor: pointer;
font-size: 10px;
transition: all 0.1s ease;
}
.tab-btn:hover {
background: #f0f0f0;
}
.tab-btn.active {
background: #3498db;
color: white;
transform: translateY(2px);
}
.tab-content {
min-height: 200px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 8px;
background: #f8f8f8;
border: 2px solid #000;
font-size: 10px;
}
.stat-label {
font-weight: bold;
}
.stat-value {
color: #3498db;
}
.placeholder-text {
text-align: center;
color: #888;
font-size: 10px;
padding: 40px 20px;
}
</style>

View File

@ -1,468 +0,0 @@
<template>
<div v-if="isOpen" class="inventory-modal" @click.self="close">
<div class="inventory-container">
<!-- 標題欄 -->
<div class="inventory-header">
<h3>🎒 Inventory</h3>
<button class="close-btn" @click="close"></button>
</div>
<!-- 主要內容 -->
<div class="inventory-content">
<!-- 左側角色裝備 -->
<div class="equipment-panel">
<div class="character-display">
<div class="pet-preview"></div>
</div>
<div class="equipment-slots">
<div class="equip-slot head" title="Head">
<span class="slot-icon">🎩</span>
</div>
<div class="equip-slot body" title="Body">
<span class="slot-icon">👕</span>
</div>
<div class="equip-slot accessory" title="Accessory">
<span class="slot-icon">💍</span>
</div>
</div>
<!-- 快速資訊 -->
<div class="quick-stats">
<div class="stat-row">
<span>💰</span>
<span>{{ coins }}</span>
</div>
<div class="stat-row">
<span></span>
<span>{{ attack }}</span>
</div>
<div class="stat-row">
<span>🛡</span>
<span>{{ defense }}</span>
</div>
</div>
</div>
<!-- 右側物品格子 -->
<div class="items-panel">
<!-- 分類標籤 -->
<div class="category-tabs">
<button
v-for="category in categories"
:key="category.id"
:class="['tab', { active: activeCategory === category.id }]"
@click="activeCategory = category.id"
>
{{ category.icon }} {{ category.name }}
</button>
</div>
<!-- 物品網格 -->
<div class="item-grid">
<div
v-for="i in 24"
:key="i"
class="item-slot"
:class="{ filled: items[i - 1] }"
@click="selectItem(i - 1)"
>
<span v-if="items[i - 1]" class="item-icon">
{{ items[i - 1].icon }}
</span>
<span v-if="items[i - 1] && items[i - 1].count > 1" class="item-count">
{{ items[i - 1].count }}
</span>
</div>
</div>
<!-- 底部操作欄 -->
<div class="action-bar">
<button class="action-btn" @click="useItem">Use</button>
<button class="action-btn" @click="dropItem">Drop</button>
<button class="action-btn sort" @click="sortItems">Sort</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
isOpen: {
type: Boolean,
default: false
},
coins: {
type: Number,
default: 0
},
attack: {
type: Number,
default: 10
},
defense: {
type: Number,
default: 5
}
})
const emit = defineEmits(['close', 'use-item', 'drop-item'])
const activeCategory = ref('all')
const selectedSlot = ref(null)
const categories = [
{ id: 'all', name: 'All', icon: '📦' },
{ id: 'equipment', name: 'Gear', icon: '⚔️' },
{ id: 'consumable', name: 'Food', icon: '🍖' },
{ id: 'material', name: 'Items', icon: '💎' }
]
//
const items = ref([
{ icon: '🍖', count: 5 },
{ icon: '💊', count: 3 },
{ icon: '🗡️', count: 1 },
{ icon: '🛡️', count: 1 },
null, null, null, null,
{ icon: '💎', count: 10 },
null, null, null,
null, null, null, null,
null, null, null, null,
null, null, null, null
])
const close = () => {
emit('close')
}
const selectItem = (index) => {
selectedSlot.value = index
}
const useItem = () => {
if (selectedSlot.value !== null && items.value[selectedSlot.value]) {
emit('use-item', items.value[selectedSlot.value])
}
}
const dropItem = () => {
if (selectedSlot.value !== null && items.value[selectedSlot.value]) {
emit('drop-item', items.value[selectedSlot.value])
items.value[selectedSlot.value] = null
}
}
const sortItems = () => {
//
const nonNullItems = items.value.filter(item => item !== null)
items.value = [...nonNullItems, ...Array(24 - nonNullItems.length).fill(null)]
}
</script>
<style scoped>
.inventory-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.inventory-container {
width: 90%;
max-width: 800px;
max-height: 90vh;
background: var(--color-panel);
border: 3px solid var(--color-accent);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
overflow-y: auto;
}
/* 手機直向 */
@media (max-width: 480px) {
.inventory-container {
width: 95%;
max-width: none;
max-height: 95vh;
border-radius: 12px;
}
}
/* 平板 */
@media (min-width: 481px) and (max-width: 768px) {
.inventory-container {
width: 92%;
}
}
.inventory-header {
background: linear-gradient(135deg, #a29bfe 0%, #6c5ce7 100%);
padding: 16px 20px;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 3px solid #5a4a3a;
}
.inventory-header h3 {
margin: 0;
color: white;
font-size: 18px;
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.3);
}
.close-btn {
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid white;
border-radius: 8px;
color: white;
font-size: 18px;
cursor: pointer;
transition: all 0.2s;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.inventory-content {
display: grid;
grid-template-columns: 200px 1fr;
gap: 16px;
padding: 16px;
min-height: 500px;
}
/* 手機直向:垂直堆疊 */
@media (max-width: 480px) {
.inventory-content {
grid-template-columns: 1fr;
gap: 12px;
padding: 12px;
min-height: auto;
}
.equipment-panel {
max-height: 200px;
}
}
/* 手機橫向 */
@media (max-width: 768px) and (orientation: landscape) {
.inventory-content {
min-height: 300px;
}
}
/* 左側裝備欄 */
.equipment-panel {
background: rgba(255, 255, 255, 0.6);
border: 3px solid #5a4a3a;
border-radius: 12px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.character-display {
width: 100%;
height: 150px;
background: linear-gradient(135deg, #ffeaa7 0%, #fdcb6e 100%);
border: 3px solid #5a4a3a;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.pet-preview {
width: 80px;
height: 80px;
background: #ff6b9d;
border: 3px solid #5a4a3a;
border-radius: 50%;
}
.equipment-slots {
display: flex;
flex-direction: column;
gap: 8px;
}
.equip-slot {
height: 48px;
background: rgba(255, 255, 255, 0.8);
border: 3px solid #5a4a3a;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
cursor: pointer;
transition: all 0.2s;
}
.equip-slot:hover {
background: #ffeaa7;
transform: scale(1.05);
}
.quick-stats {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 8px;
border-top: 2px solid #5a4a3a;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.5);
border-radius: 6px;
font-size: 14px;
font-weight: bold;
}
/* 右側物品欄 */
.items-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.category-tabs {
display: flex;
gap: 8px;
}
.tab {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.6);
border: 3px solid #5a4a3a;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.tab:hover {
background: rgba(255, 255, 255, 0.8);
}
.tab.active {
background: linear-gradient(135deg, #74b9ff 0%, #a29bfe 100%);
color: white;
transform: translateY(-2px);
}
.item-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
background: var(--color-panel-light);
border: 2px solid var(--color-border);
border-radius: 12px;
padding: 12px;
flex: 1;
}
/* 手機直向4列 */
@media (max-width: 480px) {
.item-grid {
grid-template-columns: repeat(4, 1fr);
gap: 6px;
padding: 8px;
}
}
/* 平板5列 */
@media (min-width: 481px) and (max-width: 768px) {
.item-grid {
grid-template-columns: repeat(5, 1fr);
}
}
.item-slot {
aspect-ratio: 1;
background: rgba(255, 255, 255, 0.8);
border: 3px solid #5a4a3a;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
.item-slot:hover {
background: #ffeaa7;
transform: scale(1.05);
}
.item-slot.filled {
background: rgba(255, 255, 255, 1);
}
.item-count {
position: absolute;
bottom: 2px;
right: 4px;
font-size: 10px;
font-weight: bold;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 2px 4px;
border-radius: 4px;
}
.action-bar {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.action-btn {
padding: 12px 24px;
background: linear-gradient(135deg, #55efc4 0%, #00b894 100%);
border: 3px solid #5a4a3a;
border-radius: 8px;
color: white;
font-weight: bold;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.3);
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.action-btn:active {
transform: translateY(0);
}
.action-btn.sort {
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
}
</style>

View File

@ -1,230 +0,0 @@
<template>
<div class="rpg-hud">
<div class="hud-container">
<!-- 左側圓形頭像框 -->
<div class="avatar-circle">
<div class="circle-border-outer"></div>
<div class="circle-border-inner"></div>
<div class="pet-avatar" :class="petEmotion">
<div class="eye left"></div>
<div class="eye right"></div>
<div class="mouth"></div>
</div>
</div>
<!-- 右側血條面板 -->
<div class="bars-panel">
<!-- HP -->
<div class="stat-row">
<div class="bar-container">
<div class="bar-bg"></div>
<div class="bar-fill hp" :style="{ width: `${healthPercent}%` }"></div>
</div>
<span class="stat-value">{{ Math.round(health) }}/{{ maxHealth }}</span>
</div>
<!-- 飢餓條 -->
<div class="stat-row">
<div class="bar-container">
<div class="bar-bg"></div>
<div class="bar-fill hunger" :style="{ width: `${hunger}%` }"></div>
</div>
<span class="stat-value">{{ Math.round(hunger) }}</span>
</div>
<!-- 快樂條 -->
<div class="stat-row">
<div class="bar-container">
<div class="bar-bg"></div>
<div class="bar-fill happiness" :style="{ width: `${happiness}%` }"></div>
</div>
<span class="stat-value">{{ Math.round(happiness) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
petName: { type: String, default: 'Pet' },
health: { type: Number, default: 100 },
maxHealth: { type: Number, default: 100 },
hunger: { type: Number, default: 100 },
happiness: { type: Number, default: 100 },
petEmotion: { type: String, default: 'happy' }
})
const healthPercent = computed(() => {
return Math.min(100, Math.max(0, (props.health / props.maxHealth) * 100))
})
</script>
<style scoped>
.rpg-hud {
position: fixed;
top: 16px;
left: 16px;
z-index: 100;
}
.hud-container {
display: flex;
align-items: center;
gap: 0;
}
/* 圓形頭像框 */
.avatar-circle {
width: 64px;
height: 64px;
position: relative;
flex-shrink: 0;
}
.circle-border-outer {
position: absolute;
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--color-accent);
border: 4px solid var(--color-border);
box-shadow: 0 4px 0 rgba(0, 0, 0, 0.6), inset 0 2px 0 rgba(255, 255, 255, 0.2);
}
.circle-border-inner {
position: absolute;
top: 6px;
left: 6px;
width: 46px;
height: 46px;
border-radius: 50%;
background: var(--color-panel);
border: 3px solid var(--color-border);
}
.pet-avatar {
position: absolute;
top: 9px;
left: 9px;
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #ff6b9d 0%, #ff8fab 100%);
}
.eye {
position: absolute;
width: 5px;
height: 5px;
background: #2a2420;
border-radius: 50%;
top: 45%;
transform: translateY(-50%);
}
.eye.left { left: 28%; }
.eye.right { right: 28%; }
.mouth {
position: absolute;
width: 9px;
height: 4px;
border: 2px solid #2a2420;
border-top: none;
border-radius: 0 0 5px 5px;
bottom: 30%;
left: 50%;
transform: translateX(-50%);
}
.pet-avatar.sad .mouth {
border-radius: 5px 5px 0 0;
border-top: 2px solid #2a2420;
border-bottom: none;
}
/* 血條面板 */
.bars-panel {
background: var(--color-panel);
border: 3px solid var(--color-border);
border-left: none;
border-radius: 0 8px 8px 0;
padding: 6px 8px 6px 12px;
margin-left: -6px;
display: flex;
flex-direction: column;
gap: 3px;
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.6), inset 0 2px 0 rgba(255, 255, 255, 0.1);
}
.stat-row {
display: flex;
align-items: center;
gap: 6px;
}
.bar-container {
width: 110px;
height: 14px;
position: relative;
}
.bar-bg {
position: absolute;
width: 100%;
height: 100%;
background: var(--color-panel-light);
border: 2px solid var(--color-border);
border-radius: 2px;
box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.6);
}
.bar-fill {
position: absolute;
top: 2px;
left: 2px;
height: calc(100% - 4px);
border-radius: 1px;
transition: width 0.4s ease;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4), inset 0 -1px 0 rgba(0, 0, 0, 0.4);
}
.bar-fill.hp {
background: linear-gradient(180deg, #e74c3c 0%, #c0392b 100%);
}
.bar-fill.hunger {
background: linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent-dark) 100%);
}
.bar-fill.happiness {
background: linear-gradient(180deg, #2ecc71 0%, #27ae60 100%);
}
.stat-value {
font-size: 9px;
color: var(--color-text);
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.8);
min-width: 42px;
text-align: right;
font-weight: bold;
letter-spacing: 0.3px;
}
/* 手機直向 */
@media (max-width: 480px) {
.rpg-hud {
transform: scale(0.8);
transform-origin: top left;
}
}
/* 平板 */
@media (min-width: 481px) and (max-width: 768px) {
.rpg-hud {
transform: scale(0.9);
transform-origin: top left;
}
}
</style>

View File

@ -1,247 +0,0 @@
<template>
<PixelCard size="large" class="pet-screen">
<div class="screen-header">
<h2 class="pet-name">{{ petName }}</h2>
</div>
<div class="pet-viewport">
<div class="background-animation">
<!-- CSS animated background -->
<div class="cloud cloud-1"></div>
<div class="cloud cloud-2"></div>
<div class="cloud cloud-3"></div>
</div>
<div class="pet-sprite" :class="petEmotion">
<!-- CSS-based pet pixel art -->
<div class="pet-body"></div>
<div class="pet-eye-left"></div>
<div class="pet-eye-right"></div>
<div class="pet-mouth"></div>
</div>
<div v-if="poopCount > 0" class="poop-container">
<div v-for="i in Math.min(poopCount, 4)" :key="i" class="poop-pile">
<div class="poop-base"></div>
<div class="poop-stink"></div>
</div>
</div>
</div>
</PixelCard>
</template>
<script setup>
import PixelCard from './PixelCard.vue'
const props = defineProps({
petName: {
type: String,
default: 'Pet'
},
petStage: {
type: String,
default: 'egg'
},
petEmotion: {
type: String,
default: 'happy'
},
poopCount: {
type: Number,
default: 0
}
})
</script>
<style scoped>
.pet-screen {
width: 100%;
max-width: 600px;
margin: 0 auto;
}
.screen-header {
text-align: center;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 3px solid #000;
}
.pet-name {
font-size: 16px;
text-transform: uppercase;
}
.pet-viewport {
position: relative;
width: 100%;
height: 300px;
background: linear-gradient(180deg, #87CEEB 0%, #E0F6FF 100%);
overflow: hidden;
border: 3px solid #000;
}
/* Background clouds animation */
.background-animation {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.cloud {
position: absolute;
background: white;
border: 2px solid #ddd;
border-radius: 50%;
opacity: 0.7;
animation: float 20s infinite linear;
}
.cloud-1 {
width: 60px;
height: 30px;
top: 20%;
left: -60px;
}
.cloud-2 {
width: 80px;
height: 40px;
top: 40%;
left: -80px;
animation-delay: -7s;
animation-duration: 25s;
}
.cloud-3 {
width: 50px;
height: 25px;
top: 60%;
left: -50px;
animation-delay: -14s;
animation-duration: 30s;
}
@keyframes float {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(600px + 100%));
}
}
/* Pet sprite */
.pet-sprite {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 80px;
image-rendering: pixelated;
}
.pet-body {
width: 60px;
height: 60px;
background: #FFD700;
border: 3px solid #000;
position: absolute;
bottom: 0;
left: 10px;
border-radius: 8px;
}
.pet-eye-left,
.pet-eye-right {
width: 8px;
height: 8px;
background: #000;
border-radius: 50%;
position: absolute;
top: 20px;
}
.pet-eye-left {
left: 25px;
}
.pet-eye-right {
right: 25px;
}
.pet-mouth {
width: 20px;
height: 10px;
border: 2px solid #000;
border-top: none;
border-radius: 0 0 10px 10px;
position: absolute;
bottom: 15px;
left: 30px;
}
/* Emotions */
.pet-sprite.happy .pet-mouth {
border-radius: 0 0 20px 20px;
height: 12px;
}
.pet-sprite.sad .pet-mouth {
border-radius: 20px 20px 0 0;
border-top: 2px solid #000;
border-bottom: none;
bottom: 20px;
}
.pet-sprite.sick {
filter: hue-rotate(90deg);
}
/* Poop */
.poop-container {
position: absolute;
bottom: 60px;
left: 20px;
display: flex;
gap: 10px;
}
.poop-pile {
position: relative;
width: 30px;
height: 30px;
}
.poop-base {
width: 24px;
height: 24px;
background: #8B4513;
border: 2px solid #000;
border-radius: 50% 50% 40% 40%;
position: absolute;
bottom: 0;
}
.poop-stink {
width: 4px;
height: 12px;
background: #888;
opacity: 0.5;
position: absolute;
top: -12px;
left: 10px;
animation: stinkWave 1.5s infinite;
}
@keyframes stinkWave {
0%, 100% {
transform: translateY(0);
opacity: 0.5;
}
50% {
transform: translateY(-8px);
opacity: 0.2;
}
}
</style>

View File

@ -1,107 +0,0 @@
<template>
<button
class="pixel-button"
:class="[variantClass, sizeClass, { disabled }]"
:disabled="disabled"
@click="handleClick"
>
<slot />
</button>
</template>
<script setup>
const props = defineProps({
variant: {
type: String,
default: 'primary', // 'primary', 'success', 'danger', 'warning'
validator: (value) => ['primary', 'success', 'danger', 'warning'].includes(value)
},
size: {
type: String,
default: 'normal', // 'small', 'normal', 'large'
validator: (value) => ['small', 'normal', 'large'].includes(value)
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['click'])
const variantClass = computed(() => `variant-${props.variant}`)
const sizeClass = computed(() => `size-${props.size}`)
const handleClick = (event) => {
if (!props.disabled) {
emit('click', event)
}
}
</script>
<style scoped>
.pixel-button {
font-family: 'Press Start 2P', cursive;
border: 3px solid #000;
padding: 12px 24px;
cursor: pointer;
position: relative;
box-shadow: 4px 4px 0px rgba(0, 0, 0, 0.3);
transition: all 0.1s ease;
image-rendering: pixelated;
font-size: 12px;
text-transform: uppercase;
}
.pixel-button:hover:not(.disabled) {
transform: translate(-1px, -1px);
box-shadow: 5px 5px 0px rgba(0, 0, 0, 0.4);
}
.pixel-button:active:not(.disabled) {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0px rgba(0, 0, 0, 0.3);
}
.pixel-button.disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Variants */
.pixel-button.variant-primary {
background: var(--color-primary);
color: white;
}
.pixel-button.variant-success {
background: var(--color-success);
color: white;
}
.pixel-button.variant-danger {
background: var(--color-danger);
color: white;
}
.pixel-button.variant-warning {
background: var(--color-warning);
color: white;
}
/* Sizes */
.pixel-button.size-small {
padding: 8px 16px;
font-size: 10px;
}
.pixel-button.size-normal {
padding: 12px 24px;
font-size: 12px;
}
.pixel-button.size-large {
padding: 16px 32px;
font-size: 14px;
}
</style>

View File

@ -1,62 +0,0 @@
<template>
<div class="pixel-card" :class="[sizeClass, { clickable }]">
<slot />
</div>
</template>
<script setup>
const props = defineProps({
size: {
type: String,
default: 'normal', // 'small', 'normal', 'large'
validator: (value) => ['small', 'normal', 'large'].includes(value)
},
clickable: {
type: Boolean,
default: false
}
})
const sizeClass = computed(() => `size-${props.size}`)
</script>
<style scoped>
.pixel-card {
background: white;
border: var(--pixel-border) solid #000;
box-shadow: var(--pixel-shadow);
padding: 16px;
position: relative;
image-rendering: pixelated;
}
.pixel-card.clickable {
cursor: pointer;
transition: transform 0.1s ease;
}
.pixel-card.clickable:hover {
transform: translate(-2px, -2px);
box-shadow: 8px 8px 0px rgba(0, 0, 0, 0.3);
}
.pixel-card.clickable:active {
transform: translate(2px, 2px);
box-shadow: 4px 4px 0px rgba(0, 0, 0, 0.3);
}
.pixel-card.size-small {
padding: 8px;
font-size: 10px;
}
.pixel-card.size-normal {
padding: 16px;
font-size: 12px;
}
.pixel-card.size-large {
padding: 24px;
font-size: 14px;
}
</style>

View File

@ -1,121 +0,0 @@
<template>
<div class="pixel-progress-bar">
<div class="progress-label" v-if="label">{{ label }}</div>
<div class="progress-container">
<div
class="progress-fill"
:class="[colorClass, { striped }]"
:style="{ width: `${clampedValue}%` }"
>
<div v-if="striped" class="stripes"></div>
</div>
</div>
<div class="progress-value" v-if="showValue">
{{ current !== null ? `${Math.round(current)}/${max}` : `${Math.round(clampedValue)}%` }}
</div>
</div>
</template>
<script setup>
const props = defineProps({
value: {
type: Number,
required: true
},
max: {
type: Number,
default: 100
},
current: {
type: Number,
default: null
},
label: {
type: String,
default: ''
},
color: {
type: String,
default: 'primary', // 'primary', 'success', 'danger', 'warning'
validator: (value) => ['primary', 'success', 'danger', 'warning'].includes(value)
},
striped: {
type: Boolean,
default: false
},
showValue: {
type: Boolean,
default: true
}
})
const clampedValue = computed(() => {
if (props.current !== null) {
return Math.min(100, Math.max(0, (props.current / props.max) * 100))
}
return Math.min(100, Math.max(0, props.value))
})
const colorClass = computed(() => `color-${props.color}`)
</script>
<style scoped>
.pixel-progress-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
}
.progress-label {
min-width: 80px;
font-weight: bold;
}
.progress-container {
flex: 1;
height: 24px;
background: #ddd;
border: 3px solid #000;
position: relative;
overflow: hidden;
}
.progress-fill {
height: 100%;
transition: width 0.3s ease;
position: relative;
}
.progress-fill.color-primary {
background: var(--color-primary);
}
.progress-fill.color-success {
background: var(--color-success);
}
.progress-fill.color-danger {
background: var(--color-danger);
}
.progress-fill.color-warning {
background: var(--color-warning);
}
.progress-fill.striped {
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgba(0, 0, 0, 0.1) 10px,
rgba(0, 0, 0, 0.1) 20px
);
}
.progress-value {
min-width: 70px;
text-align: right;
font-weight: bold;
}
</style>

View File

@ -1,337 +0,0 @@
<template>
<div class="scrollable-scene">
<!-- 背景層 -->
<div class="scene-background" :style="{ transform: `translateX(${-scrollPosition}px)` }">
<!-- 遠景 -->
<div class="bg-layer far-bg">
<div class="cloud" style="left: 100px; top: 50px;"></div>
<div class="cloud" style="left: 400px; top: 80px;"></div>
<div class="cloud" style="left: 700px; top: 40px;"></div>
<div class="mountain" style="left: 200px;"></div>
<div class="mountain" style="left: 600px;"></div>
</div>
<!-- 中景 -->
<div class="bg-layer mid-bg">
<div class="tree" style="left: 150px;"></div>
<div class="tree" style="left: 550px;"></div>
<div class="tree" style="left: 900px;"></div>
</div>
</div>
<!-- 前景 - 地面 -->
<div class="scene-ground">
<div class="grass-pattern"></div>
</div>
<!-- 寵物 -->
<div
class="pet-character"
:class="[petEmotion, { walking: isWalking }]"
:style="{ left: petPosition + 'px' }"
>
<div class="pet-body"></div>
</div>
<!-- 互動物件 -->
<div class="scene-objects">
<div class="object food-bowl" style="left: 300px;" @click="$emit('interact', 'food')">
🍖
</div>
<div class="object toy" style="left: 500px;" @click="$emit('interact', 'toy')">
🎾
</div>
<div class="object bed" style="left: 700px;" @click="$emit('interact', 'bed')">
🛏
</div>
</div>
<!-- 捲軸控制 (開發用) -->
<div class="scroll-controls">
<button @click="scrollLeft" class="scroll-btn"></button>
<button @click="scrollRight" class="scroll-btn"></button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
petEmotion: {
type: String,
default: 'happy'
}
})
const emit = defineEmits(['interact'])
const scrollPosition = ref(0)
const petPosition = ref(200)
const isWalking = ref(false)
const scrollLeft = () => {
if (scrollPosition.value > 0) {
scrollPosition.value = Math.max(0, scrollPosition.value - 100)
}
}
const scrollRight = () => {
if (scrollPosition.value < 600) {
scrollPosition.value = Math.min(600, scrollPosition.value + 100)
}
}
// ()
setInterval(() => {
const randomMove = Math.random() > 0.5
if (randomMove) {
isWalking.value = true
petPosition.value += (Math.random() - 0.5) * 50
petPosition.value = Math.max(50, Math.min(750, petPosition.value))
setTimeout(() => {
isWalking.value = false
}, 1000)
}
}, 3000)
</script>
<style scoped>
.scrollable-scene {
width: 100%;
height: 400px;
position: relative;
overflow: hidden;
background: linear-gradient(180deg, #87CEEB 0%, #E0F6FF 60%, #98D8C8 100%);
border: 4px solid #5a4a3a;
border-radius: 16px;
}
/* 手機直向 */
@media (max-width: 480px) {
.scrollable-scene {
height: 280px;
border: 3px solid #5a4a3a;
border-radius: 12px;
}
}
/* 手機橫向 */
@media (max-width: 768px) and (orientation: landscape) {
.scrollable-scene {
height: 220px;
}
}
/* 平板 */
@media (min-width: 481px) and (max-width: 768px) {
.scrollable-scene {
height: 320px;
}
}
.scene-background {
position: absolute;
width: 1200px;
height: 100%;
transition: transform 0.3s ease;
}
.bg-layer {
position: absolute;
width: 100%;
height: 100%;
}
/* 雲朵 */
.cloud {
position: absolute;
width: 80px;
height: 40px;
background: white;
border-radius: 50px;
opacity: 0.8;
animation: float 20s infinite linear;
}
.cloud::before,
.cloud::after {
content: '';
position: absolute;
background: white;
border-radius: 50%;
}
.cloud::before {
width: 50px;
height: 50px;
top: -25px;
left: 10px;
}
.cloud::after {
width: 60px;
height: 45px;
top: -20px;
right: 10px;
}
@keyframes float {
from { transform: translateX(0); }
to { transform: translateX(100px); }
}
/* 山 */
.mountain {
position: absolute;
bottom: 50px;
width: 0;
height: 0;
border-left: 100px solid transparent;
border-right: 100px solid transparent;
border-bottom: 150px solid #95a5a6;
}
/* 樹 */
.tree {
position: absolute;
bottom: 80px;
width: 20px;
height: 60px;
background: #8B4513;
border-radius: 4px;
}
.tree::before {
content: '';
position: absolute;
top: -30px;
left: -20px;
width: 60px;
height: 60px;
background: #55efc4;
border-radius: 50%;
}
/* 地面 */
.scene-ground {
position: absolute;
bottom: 0;
width: 100%;
height: 80px;
background: linear-gradient(180deg, #6ab04c 0%, #4cd137 100%);
border-top: 3px solid #5a4a3a;
}
.grass-pattern {
width: 100%;
height: 100%;
background-image: repeating-linear-gradient(
90deg,
transparent,
transparent 10px,
rgba(0, 0, 0, 0.05) 10px,
rgba(0, 0, 0, 0.05) 20px
);
}
/* 寵物 */
.pet-character {
position: absolute;
bottom: 80px;
width: 80px;
height: 80px;
transition: left 0.5s ease;
z-index: 10;
}
.pet-body {
width: 60px;
height: 60px;
background: linear-gradient(135deg, #ff6b9d 0%, #ff8fab 100%);
border: 3px solid #5a4a3a;
border-radius: 50%;
position: relative;
box-shadow: 0 4px 0 rgba(0, 0, 0, 0.2);
}
.pet-body::before,
.pet-body::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
background: #2d3436;
border-radius: 50%;
top: 20px;
}
.pet-body::before {
left: 15px;
}
.pet-body::after {
right: 15px;
}
.pet-character.walking .pet-body {
animation: bounce 0.5s infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
/* 互動物件 */
.scene-objects {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
}
.object {
position: absolute;
bottom: 80px;
font-size: 32px;
cursor: pointer;
pointer-events: all;
transition: transform 0.2s;
}
.object:hover {
transform: scale(1.2);
}
/* 捲軸控制 */
.scroll-controls {
position: absolute;
bottom: 16px;
right: 16px;
display: flex;
gap: 8px;
z-index: 20;
}
.scroll-btn {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.9);
border: 3px solid #5a4a3a;
border-radius: 8px;
font-size: 20px;
cursor: pointer;
transition: all 0.2s;
}
.scroll-btn:hover {
background: #ffeaa7;
transform: scale(1.1);
}
.scroll-btn:active {
transform: scale(0.95);
}
</style>

View File

@ -1,120 +0,0 @@
<template>
<PixelCard class="status-panel">
<h3 class="panel-title">Status</h3>
<div class="status-bars">
<PixelProgressBar
label="Hunger"
:value="hunger"
:max="100"
:current="hunger"
color="warning"
/>
<PixelProgressBar
label="Happiness"
:value="happiness"
:max="100"
:current="happiness"
color="primary"
/>
<PixelProgressBar
label="Health"
:value="health"
:max="maxHealth"
:current="health"
color="success"
/>
</div>
<div class="status-badges">
<span v-if="isSleeping" class="badge badge-info">💤 Sleeping</span>
<span v-if="isSick" class="badge badge-danger">🤒 Sick</span>
<span v-if="poopCount > 0" class="badge badge-warning">💩 x{{ poopCount }}</span>
</div>
</PixelCard>
</template>
<script setup>
import PixelCard from './PixelCard.vue'
import PixelProgressBar from './PixelProgressBar.vue'
const props = defineProps({
hunger: {
type: Number,
default: 50
},
happiness: {
type: Number,
default: 50
},
health: {
type: Number,
default: 50
},
maxHealth: {
type: Number,
default: 100
},
isSleeping: {
type: Boolean,
default: false
},
isSick: {
type: Boolean,
default: false
},
poopCount: {
type: Number,
default: 0
}
})
</script>
<style scoped>
.status-panel {
width: 100%;
}
.panel-title {
font-size: 14px;
margin-bottom: 16px;
text-align: center;
text-transform: uppercase;
padding-bottom: 8px;
border-bottom: 2px solid #000;
}
.status-bars {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.status-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.badge {
padding: 4px 8px;
border: 2px solid #000;
font-size: 10px;
display: inline-block;
}
.badge-info {
background: #3498db;
color: white;
}
.badge-danger {
background: #e74c3c;
color: white;
}
.badge-warning {
background: #f39c12;
color: white;
}
</style>

View File

@ -27,10 +27,12 @@ export default defineNuxtConfig({
head: {
viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover',
meta: [
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
{ name: 'theme-color', content: '#6b6250' }
]
}
}
},
css: ['~/assets/css/tailwind.css']
})

10
package-lock.json generated
View File

@ -17,6 +17,7 @@
"@unhead/vue": "^2.0.19",
"better-sqlite3": "^12.4.6",
"eslint": "^9.39.1",
"lucide-vue-next": "^0.554.0",
"nuxt": "^4.2.1",
"typescript": "^5.9.3",
"vue": "^3.5.24",
@ -11623,6 +11624,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-vue-next": {
"version": "0.554.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.554.0.tgz",
"integrity": "sha512-nDchDVm/J3mv+7aYtDh7aLkeBVtzDNyaelKEOlhAE0MKMtDfB9fFatx2siqZUBYhLHjMK5DZnaAC/ODT9vQ63Q==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-regexp": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.10.0.tgz",

View File

@ -20,6 +20,7 @@
"@unhead/vue": "^2.0.19",
"better-sqlite3": "^12.4.6",
"eslint": "^9.39.1",
"lucide-vue-next": "^0.554.0",
"nuxt": "^4.2.1",
"typescript": "^5.9.3",
"vue": "^3.5.24",

38
tailwind.config.ts Normal file
View File

@ -0,0 +1,38 @@
import type { Config } from 'tailwindcss'
export default <Config>{
content: [
'./components/**/*.{js,vue,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'./plugins/**/*.{js,ts}',
'./app.vue',
'./error.vue',
'./app/components/**/*.{js,vue,ts}',
'./app/layouts/**/*.vue',
'./app/pages/**/*.vue',
'./app/app.vue'
],
theme: {
extend: {
colors: {
// Pixel Dungeon Palette
pixel: {
white: '#e0d8f0',
black: '#1b1026', // Dark Purple-Black
bg: '#0f0816', // Darker BG
primary: '#f6b26b', // Orange/Gold
secondary: '#2ce8f4', // Cyan
accent: '#d95763', // Red
green: '#99e550',
yellow: '#ffe762',
purple: '#8f80a0',
darkPurple: '#4a3b5e',
panel: '#2b193f',
panelDark: '#1b1026',
panelBorder: '#4a3b5e'
}
}
}
}
}