UNI-APP 기반 수선 농장 텍스트 게임 프론트엔드 개발 가이드

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 프로젝트 초기화 및 환경 설정

프로젝트 생성 시 핵심 결정 사항:

  1. 템플릿 선택: Vue3 기본 템플릿 사용으로 불필요한 복잡성 방지
  2. 디렉터리 구조: 파일 유형이 아닌 기능 모듈별로 구성
  3. 개발 도구 설정: 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 상태 관리

게임 상태의 특성과 과제:

  1. 상태 복잡성: 여러 모듈이 서로 연관됨
  2. 실시간성: 농작물 성장 타이머 필요
  3. 데이터 영속성: 게임 저장/로드 지원
  4. 오프라인 계산: 오프라인 수익 계산

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 핵심 페이지 개발 전략

페이지 아키텍처 설계 원칙:

  1. 컴포넌트 기반 개발: 재사용 가능한 컴포넌트 추출
  2. 관심사 분리: UI, 로직, 상태 분리
  3. 성능 최적화: 지연 로딩, 가상 스크롤
  4. 사용자 경험: 로딩 상태, 에러 메시지

메인 게임 페이지 아키텍처:

<!-- 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 최적화

컴포넌트 설계 원칙:

  1. 단일 책임: 각 컴포넌트는 하나의 역할만 수행
  2. 재사용성: 공통 컴포넌트 추출
  3. 설정 가능성: props를 통한 동작 제어
  4. 유지보수성: 명확한 인터페이스 문서화

농장 필드 컴포넌트 구현:

<!-- 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 스타일 시스템 및 테마 설계

디자인 시스템 구축:

  1. 색상 시스템: 수선 테마 기반 컬러 팔레트
  2. 간격 시스템: 8px 기준의 간격 규칙
  3. 폰트 시스템: 가독성 높은 글자 크기 체계
  4. 애니메이션 시스템: 일관된 전환 효과

전역 스타일 시스템 구현:

// 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 컴포넌트 설계 철학

  1. 원자 디자인 이론 적용:
    • 원자: 기본 UI 컴포넌트 (버튼, 입력창)
    • 분자: 조합 컴포넌트 (작물 카드, 농장 필드)
    • 유기체: 페이지 블록 (상태 바, 네비게이션 바)
    • 템플릿: 페이지 프레임워크
    • 페이지: 완성된 페이지
  2. 상태 기반 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);

태그: uni-app vue3 Pinia 게임개발 크로스플랫폼

7월 15일 00:21에 게시됨