habits.andr33v.ru/app/pages/village.vue

604 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page-container village-page-layout">
<h1>Моя деревня</h1>
<div v-if="pending" class="loading">Загрузка вашей деревни...</div>
<div v-else-if="error" class="error-container">
<p v-if="error.statusCode === 401">Пожалуйста, войдите, чтобы увидеть свою деревню.</p>
<div v-else>
<p>Произошла ошибка при загрузке данных о деревне. Пожалуйста, попробуйте снова.</p>
<pre>{{ error }}</pre>
</div>
</div>
<div v-else-if="villageData">
<VillageGrid
:village-data="villageData"
:selected-tile="selectedTile"
@tile-click="selectTile"
/>
<!-- Tile Info Overlay -->
<div v-if="selectedTile" class="tile-overlay-backdrop" @click="selectedTile = null">
<div class="tile-overlay-panel" @click.stop>
<h2>{{ getTileTitle(selectedTile) }}</h2>
<p class="tile-description">{{ getTileDescription(selectedTile) }}</p>
<div v-if="selectedTile.availableActions && selectedTile.availableActions.length > 0" class="actions-container">
<h3 class="actions-header">Что здесь можно сделать?</h3>
<div class="actions-list">
<!-- Build Actions -->
<div v-if="selectedTile.availableActions.some(a => a.type === 'BUILD')" class="build-section">
<div class="build-card-grid">
<div
v-for="action in selectedTile.availableActions.filter(a => a.type === 'BUILD')"
:key="action.buildingType"
class="building-card"
:class="{ disabled: !action.isEnabled }"
>
<div class="building-icon">{{ getBuildingEmoji(action.buildingType) }}</div>
<h5>{{ getBuildingName(action.buildingType) }}</h5>
<p class="building-description">{{ getBuildingDescription(action.buildingType) }}</p>
<div class="building-footer">
<button :disabled="!action.isEnabled || isSubmitting" @click="handleActionClick(action)" class="btn btn-primary btn-sm btn-full-width">
{{ getActionLabel(action) }}
</button>
</div>
<div v-if="!action.isEnabled" class="disabled-overlay">
<span>{{ getDisabledReasonText(action) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<button @click="selectedTile = null" class="btn btn-secondary close-overlay-button">Закрыть</button>
</div>
</div>
<div class="bottom-content">
<!-- Admin Panel -->
<div v-if="villageData?.user?.id === 1" class="admin-panel">
<h3>Admin Tools</h3>
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
<button @click="handleAddCoins" :disabled="isSubmittingAdminAction">Add 1000 Coins</button>
</div>
<!-- Event Log -->
<div v-if="villageEvents?.length" class="event-log-container">
<h2>Журнал событий</h2>
<div class="event-list">
<div v-for="event in villageEvents" :key="event.id" class="event-card">
<div class="event-card-header">
<span class="event-date">{{ new Date(event.createdAt).toLocaleString() }}</span>
<div class="event-rewards">
<span v-if="event.coins" class="event-reward-tag coins">{{ event.coins }} 💰</span>
<span v-if="event.exp" class="event-reward-tag exp">{{ event.exp }} ✨</span>
</div>
</div>
<p class="event-message">{{ formatMessageCoordinates(event.message) }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const { formatCoordinates, formatMessageCoordinates } = useVillageHelpers();
const { user, isAuthenticated, logout, updateUser } = useAuth(); // Destructure updateUser
const { data: villageData, pending, error, refresh: refreshVillageData } = await useFetch('/api/village', {
lazy: true,
server: false, // Ensure this runs on the client-side
});
const { data: villageEvents, refresh: refreshEvents } = await useFetch('/api/village/events', {
lazy: true,
server: false,
});
const selectedTile = ref(null);
const selectTile = (tile) => {
selectedTile.value = tile;
};
const getActionLabel = (action) => {
if (action.type === 'BUILD') {
return `${action.cost} монет`; // Return cost instead of "Построить"
}
return action.type;
};
const getBuildingName = (buildingType) => {
const buildingNameMap = {
'HOUSE': 'Дом',
'FIELD': 'Поле',
'LUMBERJACK': 'Домик лесоруба',
'QUARRY': 'Каменоломня',
'WELL': 'Колодец',
};
return buildingNameMap[buildingType] || buildingType;
};
const getBuildingEmoji = (buildingType) => {
const emojiMap = {
'HOUSE': '🏠',
'FIELD': '🌱',
'LUMBERJACK': '🪓',
'QUARRY': '⛏️',
'WELL': '💧',
};
return emojiMap[buildingType] || '❓';
};
const getTileTitle = (tile) => {
if (tile.object) {
const buildingMap = {
'HOUSE': 'Дом',
'FIELD': 'Поле',
'LUMBERJACK': 'Домик лесоруба',
'QUARRY': 'Каменоломня',
'WELL': 'Колодец',
};
return `${buildingMap[tile.object.type] || 'Неизвестное строение'} ${formatCoordinates(tile.x, tile.y)}`;
}
const terrainMap = {
'BLOCKED_TREE': 'Лесной участок',
'BLOCKED_STONE': 'Каменистый участок',
'EMPTY': 'Пустырь',
};
return `${terrainMap[tile.terrainType] || 'Неизвестная земля'} ${formatCoordinates(tile.x, tile.y)}`;
};
const getTileDescription = (tile) => {
if (tile.terrainState === 'CLEARING') return 'Идет расчистка...';
if (tile.object) return `Здесь стоит ${tile.object.type}.`;
const descriptionMap = {
'BLOCKED_TREE': 'Густые деревья, которые можно расчистить с помощью лесоруба.',
'BLOCKED_STONE': 'Каменные завалы, которые можно убрать с помощью каменотеса.',
'EMPTY': 'Свободное место, готовое к застройке.',
};
return descriptionMap[tile.terrainType] || 'Это место выглядит странно.';
};
const getBuildingDescription = (buildingType) => {
const descriptions = {
'HOUSE': 'Увеличивает лимит рабочих на 1. Рабочие нужны для производственных зданий.',
'FIELD': 'Ежедневно приносит опыт. Производство можно увеличить, построив рядом колодец.',
'LUMBERJACK': 'Позволяет вашим рабочим расчищать участки с деревьями.',
'QUARRY': 'Позволяет вашим рабочим разбирать каменные завалы.',
'WELL': 'Увеличивает производство опыта на соседних полях.',
};
return descriptions[buildingType] || '';
};
const getDisabledReasonText = (action) => {
const reasons = {
'Not enough coins': 'Не хватает монет.',
'Not enough workers': 'Не хватает свободных рабочих (постройте больше домов).',
'Requires Lumberjack': 'Нужен домик лесоруба, чтобы убирать деревья.',
'Requires Quarry': 'Нужна каменоломня, чтобы убирать камни.',
};
return reasons[action.disabledReason] || action.disabledReason;
};
const isSubmitting = ref(false);
const handleActionClick = async (action) => {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
const response = await useFetch('/api/village/action', {
method: 'POST',
body: {
tileId: selectedTile.value.id,
actionType: action.type,
payload: {
...(action.type === 'BUILD' && { buildingType: action.buildingType }),
},
},
});
if (response.error.value) {
alert(response.error.value.data?.statusMessage || 'An unknown error occurred.');
} else {
villageData.value = response.data.value;
updateUser(response.data.value.user); // Update global user state
selectedTile.value = null;
await refreshEvents(); // Refresh the event log
}
} catch (e) {
console.error('Failed to perform action:', e);
alert('An unexpected error occurred. Please check the console.');
} finally {
isSubmitting.value = false;
}
};
const isSubmittingAdminAction = ref(false);
async function handleAdminAction(url: string) {
if (isSubmittingAdminAction.value) return;
isSubmittingAdminAction.value = true;
try {
// 1. Perform the requested admin action (e.g., reset, trigger tick)
const { error: actionError } = await useFetch(url, { method: 'POST' });
if (actionError.value) {
// If the action itself fails, throw to stop execution
throw actionError.value;
}
// 2. Refresh the main village data. This runs the core game logic
// on the backend and gets the updated state.
await refreshVillageData();
// 3. The `villageData` ref is now updated. If it contains a user object,
// we sync it with the global auth state to update the header.
if (villageData.value?.user) {
updateUser(villageData.value.user);
}
// 4. Refresh the event log to show any new events created by the action.
await refreshEvents();
} catch (e: any) {
console.error(`Failed to perform admin action at ${url}:`, e);
// Use the error's data object if it exists for a more specific message
alert(e.data?.statusMessage || e.message || 'An unexpected error occurred during the admin action.');
} finally {
isSubmittingAdminAction.value = false;
}
}
const handleResetVillage = () => handleAdminAction('/api/admin/village/reset');
const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick');
const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
</script>
<style scoped>
.village-page-layout {
--tile-size: clamp(55px, 12vw, 70px);
display: flex;
flex-direction: column;
align-items: center;
}
.loading, .error-container {
margin-top: 50px;
font-size: 1.2em;
color: #555;
text-align: center;
}
/* Overlay and other styles */
.tile-overlay-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: flex-end;
z-index: 1000;
}
.tile-overlay-panel {
background-color: var(--background-color);
width: 100%;
max-width: 500px;
padding: 24px;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
gap: 16px;
max-height: 90vh;
overflow-y: auto;
}
@media (min-width: 768px) {
.tile-overlay-backdrop {
align-items: center;
}
.tile-overlay-panel {
border-radius: 15px;
}
}
.tile-overlay-panel h2 {
text-align: center;
}
.tile-description {
text-align: center;
margin: -10px;
color: var(--text-color-light);
font-style: italic;
}
.actions-container {
margin-top: 15px;
}
.actions-header {
text-align: center;
}
.build-section {
padding-top: 15px;
border-top: 1px solid var(--border-color);
}
.building-description {
font-size: 0.85em;
color: var(--text-color-light);
margin-top: 5px;
text-align: center;
}
.close-overlay-button {
width: 100%;
margin-top: 15px;
}
.bottom-content {
width: 100%;
max-width: 800px;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
margin-top: 24px;
}
.admin-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 15px;
border: 2px dashed var(--danger-color);
border-radius: 10px;
width: 100%;
max-width: 350px;
}
.admin-panel h3 {
margin: 0 0 10px 0;
color: var(--danger-color);
}
.admin-panel button {
width: 100%;
padding: 8px;
background-color: var(--danger-color);
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
.admin-panel button:disabled {
background-color: #e9ecef;
cursor: not-allowed;
}
.event-log-container {
width: 100%;
max-width: 800px;
}
.event-log-container h2 {
text-align: center;
margin-bottom: 16px;
}
.event-list {
max-height: 400px;
overflow-y: auto;
padding-right: 10px; /* For scrollbar spacing */
display: flex;
flex-direction: column;
gap: 12px;
}
.event-card {
background-color: #fff;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid var(--border-color);
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.event-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.event-date {
font-size: 0.8rem;
color: var(--text-color-light);
}
.event-rewards {
display: flex;
gap: 8px;
}
.event-reward-tag {
font-size: 0.9rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 6px;
}
.event-reward-tag.coins {
background-color: var(--warning-color-light);
color: var(--warning-color-dark);
}
.event-reward-tag.exp {
background-color: var(--info-color-light);
color: var(--info-color-dark);
}
.event-message {
font-size: 0.95rem;
line-height: 1.5;
}
.build-card-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
}
.building-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 140px;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: #fff;
text-align: center;
transition: box-shadow 0.2s;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.building-card:not(.disabled):hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.building-card.disabled {
background-color: #f9fafb;
}
.building-icon {
font-size: 2.5em;
margin-bottom: 8px;
}
.building-card h5 {
margin: 0 0 8px 0;
font-size: 1.05em;
font-weight: 600;
}
.building-card .building-description {
font-size: 0.8rem;
line-height: 1.5;
color: var(--text-color-light);
flex-grow: 1;
margin-bottom: 16px;
}
.building-footer {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
margin-top: auto;
}
.building-footer .btn-full-width {
width: 100%;
}
.building-footer .cost {
font-weight: 600;
font-size: 0.9em;
}
.disabled-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(249, 250, 251, 0.85);
color: var(--danger-color);
font-weight: 600;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
padding: 10px;
border-radius: 8px;
cursor: not-allowed;
}
.disabled-overlay span {
font-size: 0.9em;
}
@media (max-width: 480px) {
.village-grid-wrapper {
gap: 4px;
padding: 8px;
}
.village-grid {
gap: 4px;
}
}
/* --- Responsive styles for mobile --- */
@media (max-width: 768px) {
/* No changes to village-grid-wrapper or village-grid for 'fr' units,
as the --tile-size clamping already handles mobile scaling without fr.
Removed margin/padding adjustments to keep it centered and scaled.
Removed position: relative and ::after pseudo-element for scroll shadow.
*/
.bottom-content {
width: 100%;
padding: 0 16px;
box-sizing: border-box;
}
.tile-overlay-panel {
padding: 16px; /* Reduce padding on mobile */
margin-bottom: 60px;
gap: 12px;
max-width: 95%; /* Make it almost full width */
border-top-left-radius: 15px;
border-top-right-radius: 15px;
}
.build-card-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
}
.building-card {
width: 130px; /* A fixed width that allows for 2 cards per row on most phones */
}
}
</style>