1. 서론: 아이디어를 기술로 구현하다
<작은 신선 농부>는 수선(修仙)과 농사 요소를 결합한 텍스트 게임으로, 플레이어는 영전(靈田)을 가꾸고, 단약을 제련하며, 경지를 돌파하는 독특한 수선 여정을 경험합니다. 이 글은 게임 프론트엔드를 처음부터 구축하는 전체 과정을 기술하며, 기술 선정, 아키텍처 설계, 핵심 구현부터 최종 배포까지 모든 단계를 다룹니다.
2. 기술 스택 설계 철학
2.1 UNI-APP을 선택한 이유
크로스 플랫폼 호환성: 게임은 H5, 위챗 미니프로그램, 네이티브 앱 등 여러 플랫폼을 동시에 지원해야 합니다. UNI-APP의 '한 번 개발, 다중 플랫폼 배포' 기능이 이 요구사항을 완벽히 충족시킵니다.
// UNI-APP 조건부 컴파일로 플랫폼별 코드 처리
// #ifdef H5
console.log('H5 환경에서 실행');
// #endif
// #ifdef MP-WEIXIN
console.log('위챗 미니프로그램 환경에서 실행');
// #endif
// #ifdef APP-PLUS
console.log('APP 환경에서 실행');
// #endif
개발 효율성: Vue3 문법과 HBuilderX의 IDE 지원으로 생산성을 크게 높일 수 있습니다. 핫 리로드, 실기기 디버깅, 클라우드 패키징 등 통합 기능이 환경 설정의 복잡성을 줄여줍니다.
2.2 상태 관리 설계
게임 상태는 플레이어 정보, 인벤토리, 농장 상태, 상점 아이템 등 다차원으로 복잡하게 얽혀 있습니다. Pinia를 사용한 상태 관리 설계는 다음과 같습니다:
// stores/useGameStore.js 설계 개념
export const useGameStore = defineStore('game', {
// state: 단일 데이터 소스로 데이터 일관성 유지
state: () => ({
playerInfo: null, // 플레이어 상태
backpack: { // 인벤토리
seeds: [],
pills: [],
items: []
},
farmlands: [], // 농장 상태
shopItems: [] // 상점 목록
}),
// getters: 계산 속성으로 중복 계산 방지
getters: {
// 경지 진행률을 실시간 계산하여 UI 반응성 확보
realmProgress: (state) => {
if (!state.playerInfo) return 0;
return (state.playerInfo.exp / state.playerInfo.maxExp) * 100;
}
},
// actions: 비즈니스 로직 집중 처리
actions: {
// 비동기 작업을 통합 관리하여 에러 처리와 로딩 상태 관리
async plantCrop(farmlandId, seedId) {
try {
await farmApi.plant(farmlandId, seedId);
this.updateLocalState(farmlandId, seedId);
uni.showToast({ title: '재배 성공', icon: 'success' });
} catch (error) {
this.handleError(error);
}
}
}
});
3. 상세 개발 프로세스
3.1 프로젝트 초기화 및 환경 설정
프로젝트 생성 시 핵심 결정 사항:
- 템플릿 선택: Vue3 기본 템플릿 사용으로 불필요한 복잡성 방지
- 디렉터리 구조: 파일 유형이 아닌 기능 모듈별로 구성
- 개발 도구 설정: uniapp-helper, SCSS 컴파일 등 필수 플러그인 설치
핵심 설정 파일:
// pages.json - 라우팅 및 페이지 설정
{
"pages": [
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "로그인",
"navigationStyle": "custom", // 커스텀 네비게이션 바로 UI 유연성 확보
"enablePullDownRefresh": false
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "작은 신선 농부",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8",
"app-plus": {
"background": "#efeff4",
"titleNView": false // APP에서 네이티브 네비게이션 바 숨김
}
},
"tabBar": {
"color": "#7A7E83",
"selectedColor": "#3cc51f", // 수선 테마 컬러 - 초록색
"borderStyle": "black",
"backgroundColor": "#ffffff",
"list": [
{
"pagePath": "pages/game/index",
"iconPath": "static/tabs/character.png",
"selectedIconPath": "static/tabs/character-active.png",
"text": "캐릭터"
}
// ... 기타 탭 설정
]
}
}
3.2 네트워크 요청 계층 추상화
설계 목표:
- 통합 에러 처리
- 토큰 자동 관리
- 요청/응답 인터셉터 지원
- 간결한 API 호출 방식 제공
구현 포인트:
// utils/request.js - 요청 인터셉터
const requestInterceptor = {
invoke(options) {
// 1. 토큰 자동 추가
const token = uni.getStorageSync('token');
if (token) {
options.header = {
...options.header,
'Authorization': `Bearer ${token}`
};
}
// 2. 타임아웃 설정
options.timeout = options.timeout || 10000;
// 3. 전역 로딩 제어
if (options.showLoading) {
uni.showLoading({
title: '로딩 중...',
mask: true // 중복 클릭 방지
});
}
// 4. 요청 로그 (개발 환경)
if (process.env.NODE_ENV === 'development') {
console.log(`[API Request] ${options.method} ${options.url}`, options.data);
}
return options;
}
};
// API 모듈화 설계
// api/player.js - 비즈니스 모듈별 API 구성
export const playerApi = {
auth: {
login: (username, password) => http.post('/api/auth/login', { username, password }),
register: (userData) => http.post('/api/auth/register', userData)
},
player: {
getSaves: () => http.get('/api/player/saves'),
create: (playerName) => http.post('/api/player/create', { playerName }),
select: (playerId) => http.post('/api/player/select', { playerId })
},
game: {
getFullStatus: (playerId) => http.get(`/api/player/fullStatus?playerId=${playerId}`),
saveGame: (data) => http.post('/api/game/save', data)
}
};
3.3 상태 관리
게임 상태의 특성과 과제:
- 상태 복잡성: 여러 모듈이 서로 연관됨
- 실시간성: 농작물 성장 타이머 필요
- 데이터 영속성: 게임 저장/로드 지원
- 오프라인 계산: 오프라인 수익 계산
Pinia Store 설계 패턴:
// stores/useFarmStore.js - 농장 상태 관리
export const useFarmStore = defineStore('farm', {
state: () => ({
farmlands: [],
timers: new Map() // 농작물 성장 타이머 관리
}),
actions: {
// 농장 데이터 초기화
initFarmlands(farmlandsData) {
this.farmlands = farmlandsData.map(farm => ({
...farm,
remainingTime: this.calculateRemainingTime(farm),
growthProgress: this.calculateGrowthProgress(farm)
}));
this.startGrowthTimers();
},
// 작물 심기
async plant(farmlandId, seedId) {
const farmland = this.farmlands.find(f => f.id === farmlandId);
if (!farmland || farmland.status !== '비어있음') return false;
try {
await farmApi.plant(farmlandId, seedId);
farmland.status = '재배 중';
farmland.cropId = seedId;
farmland.plantTime = new Date().toISOString();
farmland.remainingTime = this.getCropGrowTime(seedId);
this.startTimerForFarmland(farmlandId);
this.updateSeedCount(seedId, -1);
return true;
} catch (error) {
console.error('재배 실패:', error);
return false;
}
},
// 성장 타이머 시작
startTimerForFarmland(farmlandId) {
const timer = setInterval(() => {
const farmland = this.farmlands.find(f => f.id === farmlandId);
if (!farmland) {
clearInterval(timer);
return;
}
farmland.remainingTime = Math.max(0, farmland.remainingTime - 1);
farmland.growthProgress = this.calculateGrowthProgress(farmland);
if (farmland.remainingTime <= 0 && farmland.status === '재배 중') {
farmland.status = '수확 가능';
clearInterval(timer);
this.timers.delete(farmlandId);
}
}, 1000); // 1초마다 업데이트
this.timers.set(farmlandId, timer);
},
// 성장 진행률 계산
calculateGrowthProgress(farmland) {
if (farmland.status !== '재배 중') return 0;
const totalTime = this.getCropGrowTime(farmland.cropId);
const elapsedTime = totalTime - farmland.remainingTime;
return Math.min((elapsedTime / totalTime) * 100, 100);
}
}
});
3.4 핵심 페이지 개발 전략
페이지 아키텍처 설계 원칙:
- 컴포넌트 기반 개발: 재사용 가능한 컴포넌트 추출
- 관심사 분리: UI, 로직, 상태 분리
- 성능 최적화: 지연 로딩, 가상 스크롤
- 사용자 경험: 로딩 상태, 에러 메시지
메인 게임 페이지 아키텍처:
<!-- pages/game/index.vue - 게임 메인 프레임 -->
<template>
<!-- 1. 게임 상태 바 -->
<game-status-bar
:playerInfo="playerInfo"
:realmProgress="realmProgress"
/>
<!-- 2. 콘텐츠 영역 (탭 전환) -->
<view class="content-area">
<character-panel
v-if="currentTab === 0"
:playerInfo="playerInfo"
@collectOfflineEarnings="collectOfflineEarnings"
/>
<backpack-panel
v-else-if="currentTab === 1"
:backpack="backpack"
@useItem="useItem"
@sellItem="sellItem"
/>
<farmland-panel
v-else-if="currentTab === 2"
:farmlands="farmlands"
:seeds="plantableSeeds"
@plant="handlePlant"
@harvest="handleHarvest"
/>
<shop-panel
v-else-if="currentTab === 3"
:shopItems="shopItems"
:playerMoney="playerInfo.money"
@buy="handleBuy"
/>
</view>
<!-- 3. 커스텀 하단 네비게이션 -->
<custom-tabbar
:currentTab="currentTab"
@switchTab="switchTab"
/>
<!-- 4. 전역 로딩 오버레이 -->
<loading-overlay v-if="isLoading" />
<!-- 5. 전역 토스트 메시지 -->
<message-toast />
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useGameStore } from '@/stores/useGameStore';
const gameStore = useGameStore();
const currentTab = ref(0);
const isLoading = ref(false);
const playerInfo = computed(() => gameStore.playerInfo);
const backpack = computed(() => gameStore.backpack);
const farmlands = computed(() => gameStore.farmlands);
const shopItems = computed(() => gameStore.shopItems);
const plantableSeeds = computed(() => gameStore.plantableSeeds);
onMounted(async () => {
await initGame();
startAutoSave();
});
onUnmounted(() => {
stopAutoSave();
});
const initGame = async () => {
isLoading.value = true;
try {
const playerId = uni.getStorageSync('currentPlayerId');
if (!playerId) {
uni.redirectTo({ url: '/pages/player-select/player-select' });
return;
}
await gameStore.initGame(playerId);
const offlineEarnings = await gameStore.calculateOfflineEarnings();
if (offlineEarnings && (offlineEarnings.exp > 0 || offlineEarnings.money > 0)) {
showOfflineEarningsModal(offlineEarnings);
}
} catch (error) {
console.error('게임 초기화 실패:', error);
uni.showModal({
title: '로딩 실패',
content: '게임 데이터 로딩에 실패했습니다. 다시 시도해주세요.',
showCancel: false
});
} finally {
isLoading.value = false;
}
};
let autoSaveTimer = null;
const startAutoSave = () => {
autoSaveTimer = setInterval(async () => {
await gameStore.saveGame();
console.log('게임이 자동 저장되었습니다.');
}, 5 * 60 * 1000); // 5분마다 저장
};
const stopAutoSave = () => {
if (autoSaveTimer) {
clearInterval(autoSaveTimer);
}
};
</script>
3.5 컴포넌트 설계 및 UI 최적화
컴포넌트 설계 원칙:
- 단일 책임: 각 컴포넌트는 하나의 역할만 수행
- 재사용성: 공통 컴포넌트 추출
- 설정 가능성: props를 통한 동작 제어
- 유지보수성: 명확한 인터페이스 문서화
농장 필드 컴포넌트 구현:
<!-- components/game/FarmlandItem.vue -->
<template>
<view
class="farmland-item"
:class="[
`status-${farmland.status}`,
`rarity-${currentCrop?.rarity || 'common'}`
]"
@click="handleClick"
>
<view class="farmland-content">
<view class="status-indicator">
<text class="status-text">{{ statusText }}</text>
</view>
<view v-if="farmland.cropId" class="crop-display">
<text class="crop-emoji">{{ cropEmoji }}</text>
<text class="crop-name">{{ currentCrop?.name }}</text>
</view>
<view v-else class="idle-prompt">
<uni-icons type="plus" size="24" color="#95a5a6" />
<text class="idle-text">터치하여 재배</text>
</view>
<view v-if="farmland.status === '재배 중'" class="growth-progress">
<view
class="progress-bar"
:style="{ width: `${farmland.growthProgress}%` }"
></view>
<text class="progress-text">{{ formatRemainingTime }}</text>
</view>
<view class="action-buttons">
<button
v-if="farmland.status === '비어있음' && seeds.length > 0"
class="action-btn plant-btn"
@click.stop="showSeedSelector"
>
재배
</button>
<button
v-if="farmland.status === '수확 가능'"
class="action-btn harvest-btn"
@click.stop="harvest"
>
수확
</button>
<button
v-if="farmland.status === '재배 중'"
class="action-btn speedup-btn"
@click.stop="showSpeedupOptions"
:disabled="!hasSpeedupItems"
>
가속
</button>
</view>
</view>
<!-- 종자 선택 팝업 -->
<uni-popup ref="seedPopup" type="bottom">
<view class="seed-selector">
<view class="selector-header">
<text class="title">종자 선택</text>
<uni-icons
type="close"
size="20"
color="#666"
@click="closeSeedSelector"
/>
</view>
<scroll-view scroll-y class="seed-list">
<view
v-for="seed in seeds"
:key="seed.id"
class="seed-item"
@click="plant(seed.id)"
>
<crop-card :crop="seed" show-plant />
</view>
</scroll-view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useGameStore } from '@/stores/useGameStore';
import { useFarmStore } from '@/stores/useFarmStore';
const props = defineProps({
farmland: { type: Object, required: true }
});
const emit = defineEmits(['plant', 'harvest', 'speedup']);
const gameStore = useGameStore();
const farmStore = useFarmStore();
const seedPopup = ref(null);
const currentCrop = computed(() => {
if (!props.farmland.cropId) return null;
return gameStore.backpack.seeds.find(seed => seed.id === props.farmland.cropId);
});
const seeds = computed(() => gameStore.plantableSeeds);
const statusText = computed(() => {
const statusMap = {
'비어있음': '비어있음',
'재배 중': '성장 중',
'수확 가능': '수확 가능'
};
return statusMap[props.farmland.status] || props.farmland.status;
});
const cropEmoji = computed(() => {
const emojiMap = {
'평범한 쌀': '🌾',
'옥령미': '🌱',
'금수미': '🌟',
'자부미': '🍠'
};
return currentCrop.value ? emojiMap[currentCrop.value.name] || '🌱' : '🌱';
});
const formatRemainingTime = computed(() => {
const time = props.farmland.remainingTime;
if (time <= 0) return '수확 가능';
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
const seconds = Math.floor(time % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
});
const hasSpeedupItems = computed(() => {
return gameStore.backpack.items.some(item => item.type === 'SPEEDUP');
});
const handleClick = () => {
if (props.farmland.status === '비어있음') showSeedSelector();
else if (props.farmland.status === '수확 가능') harvest();
};
const showSeedSelector = () => {
if (seeds.value.length === 0) {
uni.showToast({ title: '인벤토리에 종자가 없습니다', icon: 'none' });
return;
}
seedPopup.value.open();
};
const closeSeedSelector = () => seedPopup.value.close();
const plant = async (seedId) => {
const success = await farmStore.plant(props.farmland.id, seedId);
if (success) {
seedPopup.value.close();
emit('plant', { farmlandId: props.farmland.id, seedId });
}
};
const harvest = async () => {
const result = await farmStore.harvest(props.farmland.id);
if (result) {
emit('harvest', {
farmlandId: props.farmland.id,
expGained: result.expGained,
moneyGained: result.moneyGained
});
}
};
const showSpeedupOptions = () => {
// 가속 아이템 선택 로직
};
</script>
<style lang="scss" scoped>
.farmland-item {
background: linear-gradient(135deg, #8BC34A 0%, #689F38 100%);
border-radius: 12px;
padding: 15px;
margin-bottom: 15px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&.status-비어있음 {
background: linear-gradient(135deg, #CCCCCC 0%, #999999 100%);
}
&.status-재배-중 {
background: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%);
}
&.status-수확-가능 {
animation: pulse 2s infinite;
background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);
}
&.rarity-rare { border: 2px solid #3498db; }
&.rarity-epic { border: 2px solid #9b59b6; }
&.rarity-legendary {
border: 2px solid #f1c40f;
animation: glow 2s infinite alternate;
}
.farmland-content { display: flex; flex-direction: column; align-items: center; }
.status-indicator {
align-self: flex-start; margin-bottom: 10px;
.status-text {
background: rgba(0, 0, 0, 0.2); color: white; padding: 4px 12px;
border-radius: 20px; font-size: 12px;
}
}
.crop-display {
display: flex; flex-direction: column; align-items: center; margin: 15px 0;
.crop-emoji { font-size: 48px; margin-bottom: 8px; }
.crop-name { font-size: 14px; color: white; font-weight: bold; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); }
}
.idle-prompt {
display: flex; flex-direction: column; align-items: center; margin: 20px 0;
.idle-text { margin-top: 8px; color: rgba(255, 255, 255, 0.8); font-size: 14px; }
}
.growth-progress {
width: 100%; height: 8px; background: rgba(0, 0, 0, 0.2); border-radius: 4px; margin: 15px 0; overflow: hidden;
.progress-bar { height: 100%; background: linear-gradient(135deg, #FFEB3B 0%, #FFC107 100%); border-radius: 4px; transition: width 1s linear; }
.progress-text { display: block; text-align: center; margin-top: 5px; font-size: 12px; color: white; }
}
.action-buttons {
display: flex; gap: 10px; margin-top: 15px;
.action-btn {
padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; font-weight: bold; color: white;
&:active { opacity: 0.8; }
&.plant-btn { background: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%); }
&.harvest-btn { background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%); }
&.speedup-btn {
background: linear-gradient(135deg, #2196F3 0%, #0D47A1 100%);
&:disabled { background: #CCCCCC; color: #999999; }
}
}
}
}
@keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.02); } 100% { transform: scale(1); } }
@keyframes glow { from { box-shadow: 0 0 10px #f1c40f; } to { box-shadow: 0 0 20px #f1c40f; } }
.seed-selector {
background: white; border-radius: 20px 20px 0 0; padding: 20px; max-height: 70vh;
.selector-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; .title { font-size: 18px; font-weight: bold; color: #333; } }
.seed-list { max-height: 60vh; }
}
</style>
3.6 스타일 시스템 및 테마 설계
디자인 시스템 구축:
- 색상 시스템: 수선 테마 기반 컬러 팔레트
- 간격 시스템: 8px 기준의 간격 규칙
- 폰트 시스템: 가독성 높은 글자 크기 체계
- 애니메이션 시스템: 일관된 전환 효과
전역 스타일 시스템 구현:
// uni.scss - 전역 스타일 변수 및 유틸리티
/* ===== 디자인 토큰 ===== */
$color-primary: #3cc51f; // 주 색상 - 수선 초록
$color-secondary: #667eea; // 보조 색상 - 신선 보라
$color-accent: #f1c40f; // 강조 색상 - 금색
/* 경지별 색상 */
$realm-colors: (
'범인': #95a5a6,
'연기기': #3498db,
'축기기': #9b59b6,
'금단기': #e74c3c,
'원영기': #e67e22,
'화신기': #f1c40f,
'도액기': #1abc9c,
'대승기': #34495e
);
/* 희귀도별 색상 */
$rarity-colors: (
'common': #95a5a6,
'rare': #3498db,
'epic': #9b59b6,
'legendary': #f1c40f
);
/* 간격 시스템 (8px 기준) */
$spacing-scale: (0: 0, 1: 4px, 2: 8px, 3: 12px, 4: 16px, 5: 20px, 6: 24px, 7: 32px, 8: 40px, 9: 48px, 10: 64px);
/* 폰트 시스템 */
$font-sizes: (xs: 10px, sm: 12px, base: 14px, lg: 16px, xl: 18px, 2xl: 20px, 3xl: 24px, 4xl: 32px);
/* ===== 유틸리티 클래스 생성 ===== */
// 간격 유틸리티
@each $key, $value in $spacing-scale {
.m-#{$key} { margin: $value !important; }
.mt-#{$key} { margin-top: $value !important; }
.mr-#{$key} { margin-right: $value !important; }
.mb-#{$key} { margin-bottom: $value !important; }
.ml-#{$key} { margin-left: $value !important; }
.mx-#{$key} { margin-left: $value !important; margin-right: $value !important; }
.my-#{$key} { margin-top: $value !important; margin-bottom: $value !important; }
.p-#{$key} { padding: $value !important; }
.pt-#{$key} { padding-top: $value !important; }
.pr-#{$key} { padding-right: $value !important; }
.pb-#{$key} { padding-bottom: $value !important; }
.pl-#{$key} { padding-left: $value !important; }
.px-#{$key} { padding-left: $value !important; padding-right: $value !important; }
.py-#{$key} { padding-top: $value !important; padding-bottom: $value !important; }
}
// 폰트 유틸리티
@each $key, $value in $font-sizes { .text-#{$key} { font-size: $value !important; } }
// 경지별 색상 유틸리티
@each $realm, $color in $realm-colors {
.text-realm-#{$realm} { color: $color !important; }
.bg-realm-#{$realm} { background-color: $color !important; }
.border-realm-#{$realm} { border-color: $color !important; }
}
// 희귀도별 색상 유틸리티
@each $rarity, $color in $rarity-colors {
.text-rarity-#{$rarity} { color: $color !important; }
.bg-rarity-#{$rarity} { background-color: $color !important; }
.border-rarity-#{$rarity} { border-color: $color !important; }
}
/* ===== 레이아웃 유틸리티 ===== */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.flex-1 { flex: 1; }
.flex-wrap { flex-wrap: wrap; }
/* ===== 애니메이션 유틸리티 ===== */
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.animate-fade-in { animation: fade-in 0.3s ease; }
.animate-slide-up { animation: slide-up 0.3s ease; }
.animate-pulse { animation: pulse 2s infinite; }
/* ===== 반응형 디자인 ===== */
@media (min-width: 768px) { .container { max-width: 750px; margin: 0 auto; } }
3.7 디버깅 및 성능 최적화
디버깅 전략:
1. 계층별 로그 시스템:
// utils/logger.js
const logger = {
level: process.env.NODE_ENV === 'development' ? 'debug' : 'error',
debug(...args) { if (this.level === 'debug') { console.log('[DEBUG]', new Date().toISOString(), ...args); } },
info(...args) { console.info('[INFO]', ...args); },
warn(...args) { console.warn('[WARN]', ...args); },
error(...args) { console.error('[ERROR]', ...args); this.reportError(...args); },
perf(name, fn) {
const start = performance.now();
const result = fn();
const end = performance.now();
this.debug(`[PERF] ${name}: ${(end - start).toFixed(2)}ms`);
return result;
}
};
2. 상태 스냅샷 및 타임 트래블 디버깅:
// 개발 환경 상태 디버깅
if (process.env.NODE_ENV === 'development') {
import { useGameStore } from '@/stores/useGameStore';
const gameStore = useGameStore();
uni.$store = gameStore;
uni.$snapshot = () => JSON.parse(JSON.stringify(gameStore.$state));
uni.$restore = (snapshot) => gameStore.$patch(snapshot);
}
성능 최적화 전략:
가상 목록 최적화:
<template>
<scroll-view scroll-y :scroll-top="scrollTop" @scroll="handleScroll" class="virtual-list-container">
<view :style="{ height: `${totalHeight}px`, position: 'relative' }">
<view v-for="item in visibleItems" :key="item.id"
:style="{ position: 'absolute', top: `${item.top}px`, width: '100%', height: `${itemHeight}px` }">
<crop-card :crop="item.data" />
</view>
</view>
</scroll-view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
const props = defineProps({
items: Array,
itemHeight: { type: Number, default: 100 },
bufferSize: { type: Number, default: 5 }
});
const scrollTop = ref(0);
const containerHeight = ref(0);
const totalHeight = computed(() => props.items.length * props.itemHeight);
const visibleItems = computed(() => {
if (!containerHeight.value) return [];
const startIndex = Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.bufferSize);
const endIndex = Math.min(props.items.length - 1, Math.floor((scrollTop.value + containerHeight.value) / props.itemHeight) + props.bufferSize);
return props.items.slice(startIndex, endIndex + 1).map((item, index) => ({ ...item, top: (startIndex + index) * props.itemHeight }));
});
onMounted(() => {
const query = uni.createSelectorQuery();
query.select('.virtual-list-container').boundingClientRect(data => { containerHeight.value = data.height; }).exec();
});
const handleScroll = (event) => { scrollTop.value = event.detail.scrollTop; };
</script>
이미지 지연 로딩:
<template>
<image :src="realSrc" :lazy-load="true" @load="handleLoad" @error="handleError"
class="lazy-image" :class="{ loaded: isLoaded }" :style="{ opacity: isLoaded ? 1 : 0 }" />
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
const props = defineProps({
src: String,
placeholder: { type: String, default: '/static/images/placeholder.png' }
});
const emit = defineEmits(['load', 'error']);
const isLoaded = ref(false);
const hasError = ref(false);
const realSrc = computed(() => { if (hasError.value) return props.placeholder; return isLoaded.value ? props.src : props.placeholder; });
const handleLoad = () => { isLoaded.value = true; emit('load'); };
const handleError = () => { hasError.value = true; emit('error'); };
onMounted(() => {
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => { if (entry.isIntersecting) { isLoaded.value = true; observer.unobserve(entry.target); } });
});
observer.observe(document.querySelector('.lazy-image'));
} else { isLoaded.value = true; }
});
</script>
<style scoped>.lazy-image { transition: opacity 0.3s ease; }</style>
3.8 멀티 플랫폼 패키징 및 배포 전략
패키징 설정 최적화:
// manifest.json - 멀티 플랫폼 패키징 설정
{
"name": "작은 신선 농부",
"appid": "__UNI__XXXXXXX",
"description": "수선 농장 텍스트 게임",
"versionName": "1.0.0",
"versionCode": "100",
"h5": {
"title": "작은 신선 농부",
"template": "template.h5.html",
"router": { "mode": "hash" },
"devServer": { "port": 8081, "disableHostCheck": true, "proxy": { "/api": { "target": "http://localhost:8080", "changeOrigin": true, "pathRewrite": { "^/api": "" } } } },
"optimization": { "treeShaking": { "enable": true } }
},
"mp-weixin": {
"appid": "당신의 미니프로그램 AppID",
"setting": { "urlCheck": false, "es6": true, "postcss": true, "minified": true, "enhance": true },
"usingComponents": true,
"permission": { "scope.userLocation": { "desc": "위치 정보는 게임 경험 향상에 사용됩니다" } },
"requiredPrivateInfos": ["getLocation"]
},
"app-plus": {
"distribute": {
"android": {
"permissions": ["<uses-permission android:name=\"android.permission.INTERNET\" />", "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />"],
"abiFilters": ["armeabi-v7a", "arm64-v8a"],
"minSdkVersion": 21,
"targetSdkVersion": 30
},
"ios": {
"devices": "universal",
"privacyDescription": {
"NSPhotoLibraryUsageDescription": "게임 스크린샷 저장을 위해 앨범 접근 권한이 필요합니다",
"NSCameraUsageDescription": "카메라 권한이 필요합니다",
"NSPhotoLibraryAddUsageDescription": "게임 스크린샷 저장을 위해 앨범 접근 권한이 필요합니다",
"NSLocationWhenInUseUsageDescription": "더 나은 게임 경험을 위해 위치 권한이 필요합니다"
},
"deploymentTarget": "11.0"
}
},
"splashscreen": { "alwaysShowBeforeRender": true, "waiting": true, "autoclose": true, "delay": 0 }
}
}
자동화 빌드 스크립트:
// package.json - 빌드 스크립트
{
"scripts": {
"dev:h5": "cross-env NODE_ENV=development UNI_PLATFORM=h5 vue-cli-service uni-serve",
"build:h5": "cross-env NODE_ENV=production UNI_PLATFORM=h5 vue-cli-service uni-build",
"dev:mp-weixin": "cross-env NODE_ENV=development UNI_PLATFORM=mp-weixin vue-cli-service uni-build --watch",
"build:mp-weixin": "cross-env NODE_ENV=production UNI_PLATFORM=mp-weixin vue-cli-service uni-build",
"dev:app": "cross-env NODE_ENV=development UNI_PLATFORM=app-plus vue-cli-service uni-build --watch",
"build:app": "cross-env NODE_ENV=production UNI_PLATFORM=app-plus vue-cli-service uni-build",
"analyze": "cross-env NODE_ENV=production UNI_PLATFORM=h5 vue-cli-service uni-build --report",
"lint": "eslint --ext .js,.vue src",
"lint:fix": "eslint --ext .js,.vue src --fix",
"format": "prettier --write \"src/**/*.{js,vue,json,css,scss}\""
}
}
4. 개발 철학 및 모범 사례
4.1 컴포넌트 설계 철학
- 원자 디자인 이론 적용:
- 원자: 기본 UI 컴포넌트 (버튼, 입력창)
- 분자: 조합 컴포넌트 (작물 카드, 농장 필드)
- 유기체: 페이지 블록 (상태 바, 네비게이션 바)
- 템플릿: 페이지 프레임워크
- 페이지: 완성된 페이지
- 상태 기반 UI 업데이트:
const playerInfo = ref({ exp: 0, maxExp: 100 }); const progress = computed(() => (playerInfo.value.exp / playerInfo.value.maxExp) * 100); // UI가 자동으로 상태 변화에 반응 <progress-bar :progress="progress" />
4.2 성능 최적화 철학
필요할 때 로딩:
// 라우트 지연 로딩
{ path: '/farmland', component: () => import('@/pages/farmland/index.vue') }
// 컴포넌트 지연 로딩
const CropCard = defineAsyncComponent(() => import('@/components/game/CropCard.vue'));
계산된 값 캐싱:
import { computed } from 'vue';
const expensiveCalculation = computed(() => data.value.filter(item => item.rarity === 'legendary').length);