Загрузить файлы в «server_modules»
Новая версия
This commit is contained in:
parent
dd35157869
commit
cf3c4705ce
@ -65,7 +65,7 @@ const playerAbilities = [
|
||||
type: GAME_CONFIG.ACTION_TYPE_BUFF,
|
||||
duration: 4,
|
||||
descriptionFunction: (config) => `Восст. ${config.NATURE_STRENGTH_MANA_REGEN} маны при след. атаке (${4 - 1} хода)`,
|
||||
isDelayed: true
|
||||
isDelayed: true // Этот эффект применяется ПОСЛЕ следующей атаки, а не сразу
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_DEFENSE_AURA,
|
||||
@ -73,7 +73,7 @@ const playerAbilities = [
|
||||
cost: 15,
|
||||
type: GAME_CONFIG.ACTION_TYPE_BUFF,
|
||||
duration: 3,
|
||||
grantsBlock: true,
|
||||
grantsBlock: true, // Дает эффект блока на время действия
|
||||
descriptionFunction: (config) => `Снижает урон на ${config.BLOCK_DAMAGE_REDUCTION * 100}% (${3} хода)`
|
||||
},
|
||||
{
|
||||
@ -81,7 +81,7 @@ const playerAbilities = [
|
||||
name: 'Гипнотический взгляд',
|
||||
cost: 30,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DISABLE,
|
||||
effectDuration: 2, // Длительность безмолвия
|
||||
effectDuration: 2, // Длительность безмолвия в ходах противника
|
||||
cooldown: 6,
|
||||
power: 5, // Урон в ход от взгляда
|
||||
description: 'Накладывает на противника полное безмолвие на 2 хода и наносит 5 урона каждый его ход. КД: 6 х.'
|
||||
@ -91,11 +91,11 @@ const playerAbilities = [
|
||||
name: 'Печать Слабости',
|
||||
cost: 30,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DEBUFF,
|
||||
effectDuration: 3,
|
||||
effectDuration: 3, // Длительность дебаффа
|
||||
power: 10, // Количество ресурса противника, сжигаемое каждый ход
|
||||
cooldown: 5,
|
||||
// Описание теперь может адаптироваться к ресурсу оппонента
|
||||
descriptionFunction: (config, oppStats) => `Накладывает печать, сжигающую 10 ${oppStats.resourceName} противника каждый его ход в течение 3 ходов. КД: 5 х.`
|
||||
descriptionFunction: (config, oppStats) => `Накладывает печать, сжигающую 10 ${oppStats.resourceName} противника каждый его ход в течение 3 ходов. КД: 5 х.'`
|
||||
}
|
||||
];
|
||||
|
||||
@ -107,8 +107,9 @@ const opponentAbilities = [
|
||||
cost: 20,
|
||||
type: GAME_CONFIG.ACTION_TYPE_HEAL,
|
||||
power: 25,
|
||||
successRate: 0.60, // Можно вынести в GAME_CONFIG
|
||||
successRate: 0.60, // Шанс успеха
|
||||
description: 'Исцеляет ~25 HP с 60% шансом',
|
||||
// Условие для AI: HP ниже порога
|
||||
condition: (opSt, plSt, currentGameState, config) => {
|
||||
return (opSt.currentHp / opSt.maxHp) * 100 < config.OPPONENT_HEAL_THRESHOLD_PERCENT;
|
||||
}
|
||||
@ -118,34 +119,35 @@ const opponentAbilities = [
|
||||
name: 'Эхо Безмолвия',
|
||||
cost: GAME_CONFIG.BALARD_SILENCE_ABILITY_COST,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DISABLE,
|
||||
// Описание с адаптацией
|
||||
descriptionFunction: (config) => `Шанс ${config.SILENCE_SUCCESS_RATE * 100}% заглушить случайное заклинание Елены на ${config.SILENCE_DURATION} х.`,
|
||||
// Условие для AI: HP выше порога лечения, Елена не заглушена, не на спец. КД
|
||||
condition: (opSt, plSt, currentGameState, config) => {
|
||||
const hpPercent = (opSt.currentHp / opSt.maxHp) * 100;
|
||||
const isElenaAlreadySilenced = currentGameState?.player.disabledAbilities?.length > 0 ||
|
||||
currentGameState?.player.activeEffects?.some(eff => eff.id.startsWith('playerSilencedOn_'));
|
||||
// Условие для Баларда использовать безмолвие (только против Елены)
|
||||
return hpPercent >= config.OPPONENT_HEAL_THRESHOLD_PERCENT && !isElenaAlreadySilenced && currentGameState.opponent.silenceCooldownTurns <= 0;
|
||||
return hpPercent >= config.OPPONENT_HEAL_THRESHOLD_PERCENT && !isElenaAlreadySilenced && opSt.silenceCooldownTurns <= 0;
|
||||
},
|
||||
successRateFromConfig: 'SILENCE_SUCCESS_RATE',
|
||||
durationFromConfig: 'SILENCE_DURATION',
|
||||
internalCooldownFromConfig: 'BALARD_SILENCE_INTERNAL_COOLDOWN'
|
||||
successRateFromConfig: 'SILENCE_SUCCESS_RATE', // Шанс берется из конфига
|
||||
durationFromConfig: 'SILENCE_DURATION', // Длительность берется из конфига
|
||||
internalCooldownFromConfig: 'BALARD_SILENCE_INTERNAL_COOLDOWN' // Спец. КД берется из конфига
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN,
|
||||
name: 'Похищение Света',
|
||||
cost: 10,
|
||||
type: GAME_CONFIG.ACTION_TYPE_DRAIN,
|
||||
powerManaDrain: 5,
|
||||
powerDamage: 5,
|
||||
powerHealthGainFactor: 1.0,
|
||||
powerManaDrain: 5, // Сколько маны вытягивает
|
||||
powerDamage: 5, // Сколько урона наносит дополнительно
|
||||
powerHealthGainFactor: 1.0, // Множитель для расчета лечения от вытянутой маны
|
||||
description: `Вытягивает 5 Маны у Елены, наносит 5 урона и восстанавливает себе здоровье (100% от украденного).`,
|
||||
// Условие для AI: У Елены достаточно маны, не на спец. КД
|
||||
condition: (opSt, plSt, currentGameState, config) => {
|
||||
const playerManaPercent = (plSt.currentResource / plSt.maxResource) * 100;
|
||||
const playerHasHighMana = playerManaPercent > (config.BALARD_MANA_DRAIN_HIGH_MANA_THRESHOLD || 60);
|
||||
// Условие для Баларда использовать (только против Елены)
|
||||
return playerHasHighMana && currentGameState.opponent.manaDrainCooldownTurns <= 0;
|
||||
return playerHasHighMana && opSt.manaDrainCooldownTurns <= 0;
|
||||
},
|
||||
internalCooldownValue: 3
|
||||
internalCooldownValue: 3 // Спец. КД задается здесь
|
||||
}
|
||||
];
|
||||
|
||||
@ -175,7 +177,7 @@ const almagestAbilities = [
|
||||
duration: 4,
|
||||
// Аналогично Силе Природы, но использует Темную Энергию
|
||||
descriptionFunction: (config) => `Восст. ${config.NATURE_STRENGTH_MANA_REGEN} Темной Энергии при след. атаке (${4 - 1} хода)`,
|
||||
isDelayed: true
|
||||
isDelayed: true // Этот эффект применяется ПОСЛЕ следующей атаки
|
||||
},
|
||||
{
|
||||
id: GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE,
|
||||
@ -183,7 +185,7 @@ const almagestAbilities = [
|
||||
cost: 15,
|
||||
type: GAME_CONFIG.ACTION_TYPE_BUFF,
|
||||
duration: 3,
|
||||
grantsBlock: true,
|
||||
grantsBlock: true, // Дает эффект блока на время действия
|
||||
descriptionFunction: (config) => `Создает щит, снижающий урон на ${config.BLOCK_DAMAGE_REDUCTION * 100}% (${3} хода)`
|
||||
},
|
||||
{
|
||||
@ -209,154 +211,113 @@ const almagestAbilities = [
|
||||
];
|
||||
|
||||
|
||||
// --- Система Насмешек Елены (Переработанная) ---
|
||||
// Ключи верхнего уровня: 'aiBalard' для игры против Баларда (AI)
|
||||
// 'pvpAlmagest' для игры против Альмагест (PvP)
|
||||
const elenaTauntSystem = {
|
||||
aiBalard: { // Насмешки против Баларда (AI) - существующая логика
|
||||
base: {
|
||||
mercifulAttack: [ "Балард, прошу, остановись. Еще не поздно.", /* ... другие ... */ ],
|
||||
mercifulCast: [ "Даже сейчас, я пытаюсь исцелить не только тело...", /* ... другие ... */ ],
|
||||
dominating: {
|
||||
creatorVsCreation: [ "Глина не спорит с гончаром, Балард! Прекрати жалкое сопротивление!", /* ... другие ... */ ],
|
||||
betrayalOfLight: [ "Ты мог ходить в сиянии Света! Ты ИЗБРАЛ эту гниль! Получай возмездие!", /* ... другие ... */ ],
|
||||
ingratitudeContempt: [ "Самый страшный грех - грех неблагодарности!", /* ... другие ... */ ],
|
||||
unmakingThreats: [ "Я сотру тебя с лика этой земли, как досадную ошибку!", /* ... другие ... */ ]
|
||||
}
|
||||
// --- Система Насмешек ---
|
||||
// Ключи верхнего уровня: characterKey того, кто произносит насмешку ('elena', 'almagest')
|
||||
// Внутри каждого characterKey: ключи characterKey противника ('balard', 'almagest', 'elena')
|
||||
// Внутри каждого противника: секции по триггерам (battleStart, selfCastAbility, onOpponentAction, etc.)
|
||||
|
||||
const tauntSystem = {
|
||||
elena: { // Насмешки Елены
|
||||
balard: { // Против Баларда (AI)
|
||||
// Триггер: Елена использует СВОЮ способность
|
||||
selfCastAbility: {
|
||||
[GAME_CONFIG.ABILITY_ID_HEAL]: [ "Свет лечит, Балард. Но не искаженную завистью искру.", "Я черпаю силы в Истине." ],
|
||||
[GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Прими очищающее пламя Света!", "Пусть твой мрак сгорит!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сама земля отвергает тебя, я черпаю её силу!", "Гармония природы со мной." ],
|
||||
[GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Порядок восторжествует над твоим хаосом.", "Моя вера - моя защита." ],
|
||||
[GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри мне в глаза, Балард. И слушай тишину.", "Твой разум - в моей власти." ],
|
||||
[GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя ярость иссякнет, как вода в песке, Балард!", "Твоя сила угасает." ]
|
||||
},
|
||||
onPlayerCast: {
|
||||
[GAME_CONFIG.ABILITY_ID_HEAL]: [ "Свет лечит, Балард. Но не искаженную завистью искру.", /* ... другие ... */ ],
|
||||
[GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Прими очищающее пламя Света!", /* ... другие ... */ ],
|
||||
[GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сама земля отвергает тебя, я черпаю её силу!", /* ... другие ... */ ],
|
||||
[GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Порядок восторжествует над твоим хаосом.", /* ... другие ... */ ],
|
||||
[GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри мне в глаза, Балард. И слушай тишину.", /* ... другие ... */ ],
|
||||
[GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя ярость иссякнет, как вода в песке, Балард!", /* ... другие ... */ ]
|
||||
},
|
||||
onOpponentAction: { // Действия Баларда
|
||||
[GAME_CONFIG.ABILITY_ID_BALARD_HEAL]: [ "Пытаешься отсрочить неизбежное жалкой темной силой?", /* ... другие ... */ ],
|
||||
// Триггер: Противник (Балард) совершает действие
|
||||
onOpponentAction: {
|
||||
[GAME_CONFIG.ABILITY_ID_BALARD_HEAL]: [ "Пытаешься отсрочить неизбежное жалкой темной силой?" ],
|
||||
[GAME_CONFIG.ABILITY_ID_BALARD_SILENCE]: {
|
||||
success: [ "(Сдавленный вздох)... Ничтожная попытка заглушить Слово!", /* ... другие ... */ ],
|
||||
fail: [ "Твой шепот Тьмы слаб против Света Истины!", /* ... другие ... */ ]
|
||||
success: [ "(Сдавленный вздох)... Ничтожная попытка заглушить Слово!" ],
|
||||
fail: [ "Твой шепот Тьмы слаб против Света Истины!" ]
|
||||
},
|
||||
[GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN]: [ "Ты питаешься Светом, как паразит?!", /* ... другие ... */ ],
|
||||
attackBlocked: [ "Твои удары тщетны перед щитом Порядка.", /* ... другие ... */ ],
|
||||
attackHits: [ "(Шипение боли)... Боль – лишь напоминание о твоем предательстве.", /* ... другие ... */ ]
|
||||
[GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN]: [ "Ты питаешься Светом, как паразит?!" ],
|
||||
attackBlocked: [ "Твои удары тщетны перед щитом Порядка." ], // При блоке атаки Баларда
|
||||
attackHits: [ "(Шипение боли)... Боль – лишь напоминание о твоем предательстве." ] // При попадании атаки Баларда
|
||||
},
|
||||
// Триггер: Базовая атака Елены
|
||||
basicAttack: {
|
||||
merciful: [ "Балард, прошу, остановись. Еще не поздно.", "Подумай о том, что потерял." ],
|
||||
dominating: [ // Когда HP Баларда ниже порога PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT
|
||||
"Глина не спорит с гончаром, Балард!",
|
||||
"Ты ИЗБРАЛ эту гниль! Получай возмездие!",
|
||||
"Самый страшный грех - грех неблагодарности!",
|
||||
"Я сотру тебя с лика этой земли!"
|
||||
]
|
||||
},
|
||||
// Триггер: Изменение состояния боя (например, начало, оппонент почти побежден)
|
||||
onBattleState: {
|
||||
startMerciful: [ "Балард, есть ли еще путь назад?", /* ... другие ... */ ],
|
||||
opponentNearDefeat: [ "Конец близок, Балард. Прими свою судьбу.", /* ... другие ... */ ]
|
||||
start: [ "Балард, есть ли еще путь назад?" ], // Начало AI боя с Балардом
|
||||
opponentNearDefeat: [ "Конец близок, Балард. Прими свою судьбу." ] // Балард почти побежден
|
||||
}
|
||||
},
|
||||
pvpAlmagest: { // Насмешки против Альмагест (PvP) - НОВАЯ ЛОГИКА
|
||||
base: {
|
||||
generalAttack: [ // Общие фразы при атаке
|
||||
"Тьма не победит, Альмагест!",
|
||||
"Твои иллюзии рассеются перед Светом!",
|
||||
"Пока я стою, порядок будет восстановлен!",
|
||||
"Не думай, что твои трюки остановят меня.",
|
||||
"За каждую искаженную душу - получишь сторицей!",
|
||||
"Сражайся честно, если осмелишься!",
|
||||
"Даже тень боится истинного Света."
|
||||
],
|
||||
generalCast: [ // Общие фразы при касте Еленой
|
||||
"Сила Света на моей стороне!",
|
||||
"Гармония мира укрепит меня.",
|
||||
"Твоя магия хаоса бессильна здесь.",
|
||||
"Истинная сила - в созидании, не в разрушении.",
|
||||
"Я призываю чистую энергию!",
|
||||
"Свет очистит эту землю от твоей скверны."
|
||||
]
|
||||
almagest: { // Против Альмагест (PvP)
|
||||
// Триггер: Елена использует СВОЮ способность
|
||||
selfCastAbility: {
|
||||
[GAME_CONFIG.ABILITY_ID_HEAL]: [ "Я исцеляюсь Светом, который ты отвергла.", "Жизнь восторжествует над твоей некромантией!", "Мое сияние не померкнет." ],
|
||||
[GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Очищающий огонь для твоей тьмы!", "Почувствуй гнев праведного Света!", "Это пламя ярче твоих теней!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Природа дает мне силу, а тебе - лишь презрение.", "Я черпаю из источника жизни, ты - из могилы." ],
|
||||
[GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Мой щит отразит твою злобу.", "Свет - лучшая защита.", "Твои темные чары не пройдут!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри в глаза Истине, колдунья!", "Твои лживые речи умолкнут!", "Хватит прятаться за иллюзиями!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя темная сила иссякнет!", "Я ослабляю твою связь с бездной!", "Почувствуй, как тает твоя энергия!" ]
|
||||
},
|
||||
onPlayerCast: { // Реакция на конкретные касты Елены
|
||||
[GAME_CONFIG.ABILITY_ID_HEAL]: [
|
||||
"Я исцеляюсь Светом, который ты отвергла.",
|
||||
"Жизнь восторжествует над твоей некромантией!",
|
||||
"Мое сияние не померкнет."
|
||||
],
|
||||
[GAME_CONFIG.ABILITY_ID_FIREBALL]: [
|
||||
"Очищающий огонь для твоей тьмы!",
|
||||
"Почувствуй гнев праведного Света!",
|
||||
"Это пламя ярче твоих теней!"
|
||||
],
|
||||
[GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [
|
||||
"Природа дает мне силу, а тебе - лишь презрение.",
|
||||
"Я черпаю из источника жизни, ты - из могилы.",
|
||||
"Естественный порядок против твоего искажения."
|
||||
],
|
||||
[GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [
|
||||
"Мой щит отразит твою злобу.",
|
||||
"Свет - лучшая защита.",
|
||||
"Твои темные чары не пройдут!"
|
||||
],
|
||||
[GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [
|
||||
"Смотри в глаза Истине, колдунья!",
|
||||
"Твои лживые речи умолкнут!",
|
||||
"Хватит прятаться за иллюзиями!",
|
||||
"Я вижу твою истинную, уродливую суть."
|
||||
],
|
||||
[GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [
|
||||
"Твоя темная сила иссякнет!",
|
||||
"Я ослабляю твою связь с бездной!",
|
||||
"Почувствуй, как тает твоя энергия!"
|
||||
]
|
||||
// Триггер: Противник (Альмагест) совершает действие
|
||||
onOpponentAction: {
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [ "Лечишь раны тьмой? Она лишь глубже проникнет в тебя.", "Твоя магия несет лишь порчу, даже исцеляя." ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [ "Твоя тень лишь царапает, не ранит.", "Слабый удар! Тьма делает тебя немощной." ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [ "Черпаешь силы из бездны? Она поглотит и тебя.", "Твое усиление - лишь агония искаженной энергии." ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [ "Щит из теней? Он рассыпется прахом!", "Твоя защита иллюзорна, как и твоя сила." ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ "(Сдавленно) Твои ментальные атаки отвратительны!", "Тьма в моей голове... я вырвусь!" ], // Если Елена попадает под Раскол Разума Альмагест
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ "Истощаешь мою силу? Я восстановлю ее Светом!", "Твое проклятие слабо." ], // Если Елена попадает под Проклятие Увядания Альмагест
|
||||
attackBlocked: [ "Твоя атака разбилась о мой щит Света!", "Предсказуемо и слабо, Альмагест." ], // При блоке атаки Альмагест
|
||||
attackHits: [ "(Резкий вздох) Коснулась... Но Свет исцелит рану.", "Эта царапина - ничто!", "Ты заплатишь за это!" ] // При попадании атаки Альмагест
|
||||
},
|
||||
onOpponentAction: { // Реакция на действия Альмагест
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [
|
||||
"Лечишь раны тьмой? Она лишь глубже проникнет в тебя.",
|
||||
"Твоя магия несет лишь порчу, даже исцеляя.",
|
||||
"Жалкие попытки отсрочить возмездие."
|
||||
],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [
|
||||
"Твоя тень лишь царапает, не ранит.",
|
||||
"Слабый удар! Тьма делает тебя немощной.",
|
||||
"Думала, это причинит мне боль?",
|
||||
"Свет поглотит твой мрак!"
|
||||
],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [
|
||||
"Черпаешь силы из бездны? Она поглотит и тебя.",
|
||||
"Твое усиление - лишь агония искаженной энергии.",
|
||||
"Сколько бы ты ни копила тьму, Свет сильнее."
|
||||
],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [
|
||||
"Щит из теней? Он рассыплется прахом!",
|
||||
"Твоя защита иллюзорна, как и твоя сила.",
|
||||
"Не спрячешься от Света за этой ширмой!"
|
||||
],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ // Если попала под Раскол Разума
|
||||
"(Сдавленно) Твои ментальные атаки отвратительны!",
|
||||
"Тьма в моей голове... я вырвусь!",
|
||||
"Не сломить мой дух твоими фокусами!"
|
||||
],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ // Если попала под Проклятие Увядания
|
||||
"Истощаешь мою силу? Я восстановлю ее Светом!",
|
||||
"Твое проклятие слабо.",
|
||||
"Чувствую холод... но он лишь закаляет волю!"
|
||||
],
|
||||
attackBlocked: [
|
||||
"Твоя атака разбилась о мой щит Света!",
|
||||
"Предсказуемо и слабо, Альмагест.",
|
||||
"Моя защита безупречна."
|
||||
],
|
||||
attackHits: [
|
||||
"(Резкий вздох) Коснулась... Но Свет исцелит рану.",
|
||||
"Эта царапина - ничто!",
|
||||
"Ты заплатишь за это!",
|
||||
"Боль лишь укрепляет мою решимость!"
|
||||
]
|
||||
// Триггер: Базовая атака Елены (PvP)
|
||||
basicAttack: {
|
||||
general: [ "Тьма не победит, Альмагест!", "Твои иллюзии рассеются перед Светом!", "Пока я стою, порядок будет восстановлен!" ]
|
||||
},
|
||||
// Триггер: Изменение состояния боя
|
||||
onBattleState: {
|
||||
start: [ // Начало PvP боя
|
||||
"Альмагест! Твоим темным делам пришел конец!",
|
||||
"Во имя Света, я остановлю тебя!",
|
||||
"Приготовься к битве, служительница тьмы!",
|
||||
"Эта дуэль решит судьбу многих."
|
||||
],
|
||||
opponentNearDefeat: [ // Альмагест почти побеждена
|
||||
"Твоя тьма иссякает, колдунья!",
|
||||
"Сдавайся, пока Свет не испепелил тебя!",
|
||||
"Конец твоим злодеяниям близок!",
|
||||
"Прими свое поражение!"
|
||||
]
|
||||
start: [ "Альмагест! Твоим темным делам пришел конец!", "Во имя Света, я остановлю тебя!", "Приготовься к битве, служительница тьмы!" ], // Начало PvP боя с Альмагест
|
||||
opponentNearDefeat: [ "Твоя тьма иссякает, колдунья!", "Сдавайся, пока Свет не испепелил тебя!", "Конец твоим злодеяниям близок!" ] // Альмагест почти побеждена
|
||||
}
|
||||
}
|
||||
},
|
||||
almagest: { // Насмешки Альмагест
|
||||
elena: { // Против Елены (PvP)
|
||||
// Триггер: Альмагест использует СВОЮ способность
|
||||
selfCastAbility: {
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [ "Я питаюсь слабостью, Елена!", "Тьма дает мне силу!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [ "Почувствуй холод бездны!", "Твой Свет померкнет перед моей тенью!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [ "Силы Бездны со мной!", "Моя тень становится гуще!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [ "Мой щит выкован из самой тьмы!", "Попробуй пробить это, служительница Света!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ "Твой разум сломлен!", "Умолкни, Светлая!", "Я владею твоими мыслями!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ "Твоя сила тает!", "Почувствуй гниль!", "Я истощаю твой Свет!" ]
|
||||
},
|
||||
// Триггер: Противник (Елена) совершает действие
|
||||
onOpponentAction: {
|
||||
[GAME_CONFIG.ABILITY_ID_HEAL]: [ "Исцеляешься? Твои раны слишком глубоки!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Жалкое пламя! Мои тени поглотят его!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сила земли? Смешно! Бездну ничто не остановит." ],
|
||||
[GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Твой щит из Света не спасет тебя от Тьмы!" ],
|
||||
[GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "(Сдавленно, затем смех) Попытка управлять моим разумом? Жалко!", "Ты пытаешься заглянуть в Бездну?!" ], // Если Альмагест попадает под Гипнотический взгляд Елены
|
||||
[GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Моя энергия вечна, дура!", "Это лишь раздражение!" ], // Если Альмагест попадает под Печать Слабости Елены
|
||||
attackBlocked: [ "Твой блок не спасет тебя вечно, Елена!", "Это лишь задержка." ], // При блоке атаки Елены
|
||||
attackHits: [ "Ха! Чувствуешь силу Тьмы?", "Это только начало!", "Слабость!" ] // При попадании атаки Елены
|
||||
},
|
||||
// Триггер: Базовая атака Альмагест
|
||||
basicAttack: {
|
||||
general: [ "Почувствуй мою силу!", "Тени атакуют!", "Я наношу удар!" ]
|
||||
},
|
||||
// Триггер: Изменение состояния боя
|
||||
onBattleState: {
|
||||
start: [ "Тысяча лет в заточении лишь усилили меня, Елена!", "Твой Свет скоро погаснет!", "Пора положить конец твоему господству!" ], // Начало PvP боя с Еленой
|
||||
opponentNearDefeat: [ "Твой Свет гаснет!", "Ты побеждена!", "Бездне нужен твой дух!" ] // Елена почти побеждена
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -374,10 +335,35 @@ const gameData = {
|
||||
opponentAbilities, // Балард
|
||||
almagestAbilities, // Альмагест
|
||||
|
||||
// Система насмешек (с разделением)
|
||||
elenaTauntSystem
|
||||
// Система насмешек (с разделением по персонажам и противникам)
|
||||
tauntSystem // Переименовали для лучшей структуры
|
||||
};
|
||||
|
||||
// --- Вспомогательные функции для использования ВНУТРИ data.js или модулей, которые его используют ---
|
||||
// Эти функции НЕ являются частью экспортируемого объекта gameData,
|
||||
// но могут быть вызваны внутри этого файла, например, в descriptionFunction
|
||||
// Или их можно было бы экспортировать отдельно, если они нужны другим модулям, не имеющим gameData в аргументах.
|
||||
// Но в текущей структуре gameLogic они не нужны, так как gameLogic получает gameData и имеет свои локальные хелперы.
|
||||
|
||||
// function _getCharacterData(key) {
|
||||
// if (!key) return null;
|
||||
// switch (key) {
|
||||
// case 'elena': return { baseStats: playerBaseStats, abilities: playerAbilities };
|
||||
// case 'balard': return { baseStats: opponentBaseStats, abilities: opponentAbilities };
|
||||
// case 'almagest': return { baseStats: almagestBaseStats, abilities: almagestAbilities };
|
||||
// default: console.error(`data.js::_getCharacterData: Unknown character key "${key}"`); return null;
|
||||
// }
|
||||
// }
|
||||
// function _getCharacterBaseData(key) {
|
||||
// const charData = _getCharacterData(key);
|
||||
// return charData ? charData.baseStats : null;
|
||||
// }
|
||||
// function _getCharacterAbilities(key) {
|
||||
// const charData = _getCharacterData(key);
|
||||
// return charData ? charData.abilities : null;
|
||||
// }
|
||||
|
||||
|
||||
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
|
||||
module.exports = gameData;
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -2,10 +2,36 @@
|
||||
const GAME_CONFIG = require('./config');
|
||||
const gameData = require('./data'); // Загружаем один раз на уровне модуля
|
||||
|
||||
// --- Вспомогательные Функции ---
|
||||
// --- Вспомогательные Функции для gameLogic ---
|
||||
|
||||
// Вспомогательная функция для получения данных персонажа (baseStats и abilities)
|
||||
// Нужна здесь, так как объект gameData сам по себе не имеет этих методов.
|
||||
// Принимает gameDataForLogic как аргумент для гибкости, по умолчанию использует глобальный gameData.
|
||||
function _getCharacterDataForLogic(key, gameDataForLogic = gameData) {
|
||||
if (!key) return null;
|
||||
switch (key) {
|
||||
case 'elena': return { baseStats: gameDataForLogic.playerBaseStats, abilities: gameDataForLogic.playerAbilities };
|
||||
case 'balard': return { baseStats: gameDataForLogic.opponentBaseStats, abilities: gameDataForLogic.opponentAbilities }; // Балард использует opponentAbilities
|
||||
case 'almagest': return { baseStats: gameDataForLogic.almagestBaseStats, abilities: gameDataForLogic.almagestAbilities }; // Альмагест использует almagestAbilities
|
||||
default: console.error(`_getCharacterDataForLogic: Неизвестный ключ персонажа "${key}"`); return null;
|
||||
}
|
||||
}
|
||||
// Вспомогательная функция для получения только базовых статов персонажа
|
||||
function _getCharacterBaseDataForLogic(key, gameDataForLogic = gameData) {
|
||||
const charData = _getCharacterDataForLogic(key, gameDataForLogic);
|
||||
return charData ? charData.baseStats : null;
|
||||
}
|
||||
// Вспомогательная функция для получения только способностей персонажа
|
||||
function _getCharacterAbilitiesForLogic(key, gameDataForLogic = gameData) {
|
||||
const charData = _getCharacterDataForLogic(key, gameDataForLogic);
|
||||
return charData ? charData.abilities : null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Обрабатывает активные эффекты (баффы/дебаффы) для бойца в конце его хода.
|
||||
* Длительность эффекта уменьшается на 1.
|
||||
* Периодические эффекты (DoT, ресурсный дебафф и т.п.) срабатывают, если эффект не "justCast" в этом ходу.
|
||||
* @param {Array} effectsArray - Массив активных эффектов бойца.
|
||||
* @param {Object} ownerState - Состояние бойца (currentHp, currentResource и т.д.).
|
||||
* @param {Object} ownerBaseStats - Базовые статы бойца (включая characterKey).
|
||||
@ -22,51 +48,41 @@ function processEffects(effectsArray, ownerState, ownerBaseStats, ownerId, curre
|
||||
|
||||
let effectsToRemoveIndexes = [];
|
||||
|
||||
// Важно: Сначала обрабатываем эффекты, затем уменьшаем длительность, затем удаляем.
|
||||
for (let i = 0; i < effectsArray.length; i++) {
|
||||
const eff = effectsArray[i];
|
||||
const isNatureStrengthEffect = eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK;
|
||||
|
||||
// if (isNatureStrengthEffect) { // Отладочный лог
|
||||
// console.log(`[NATURE_STRENGTH_DEBUG] processEffects for ${ownerState.name}: Effect: ${eff.name}, justCast (before): ${eff.justCast}, turnsLeft (before): ${eff.turnsLeft}`);
|
||||
// }
|
||||
|
||||
// --- Обработка эффектов с действием каждый ход (ДО уменьшения turnsLeft, если justCast === false) ---
|
||||
if (!eff.justCast) { // Эффекты, которые тикают, не должны тикать в ход наложения
|
||||
if (eff.isFullSilence && eff.power && typeof eff.power === 'number' && eff.power > 0) {
|
||||
// --- Применяем эффект (DoT, сжигание ресурса и т.п.), если он не только что наложен в этом ходу ---
|
||||
if (!eff.justCast) {
|
||||
// Обработка урона от эффектов полного безмолвия (Гипнотический Взгляд, Раскол Разума)
|
||||
// Эти эффекты наносят урон цели В КОНЦЕ ее хода
|
||||
if (eff.isFullSilence && typeof eff.power === 'number' && eff.power > 0) {
|
||||
const damage = eff.power;
|
||||
ownerState.currentHp = Math.max(0, ownerState.currentHp - damage);
|
||||
// ИСПРАВЛЕНО: Округляем результат вычитания HP
|
||||
ownerState.currentHp = Math.max(0, Math.round(ownerState.currentHp - damage));
|
||||
if (addToLogCallback) addToLogCallback(`😵 Эффект "${eff.name}" наносит ${damage} урона ${ownerName}!`, configToUse.LOG_TYPE_DAMAGE);
|
||||
}
|
||||
|
||||
// Обработка сжигания ресурса (Печать Слабости, Проклятие Увядания)
|
||||
// Эти эффекты сжигают ресурс цели В КОНЦЕ ее хода
|
||||
if ((eff.id === 'effect_' + configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || eff.id === 'effect_' + configToUse.ABILITY_ID_ALMAGEST_DEBUFF) && eff.power > 0) {
|
||||
const resourceToBurn = eff.power;
|
||||
if (ownerState.currentResource > 0) {
|
||||
const actualBurn = Math.min(ownerState.currentResource, resourceToBurn);
|
||||
ownerState.currentResource = Math.max(0, ownerState.currentResource - actualBurn);
|
||||
// ИСПРАВЛЕНО: Округляем результат вычитания ресурса
|
||||
ownerState.currentResource = Math.max(0, Math.round(ownerState.currentResource - actualBurn));
|
||||
if (addToLogCallback) addToLogCallback(`🔥 Эффект "${eff.name}" сжигает ${actualBurn} ${ownerBaseStats.resourceName} у ${ownerName}!`, configToUse.LOG_TYPE_EFFECT);
|
||||
}
|
||||
}
|
||||
// Примечание: Отложенные эффекты (например, Сила Природы) применяют свою силу в gameInstance.processPlayerAction (после атаки), а не здесь.
|
||||
}
|
||||
|
||||
// --- Уменьшение длительности эффекта ---
|
||||
if (eff.justCast) {
|
||||
eff.justCast = false;
|
||||
} else {
|
||||
// Не уменьшаем turnsLeft для эффектов, которые должны длиться до следующей атаки
|
||||
// и не имеют фиксированного числа ходов (таких как Сила Природы, если бы она так работала).
|
||||
// В нашем случае Сила Природы имеет duration, поэтому turnsLeft уменьшается.
|
||||
// --- Уменьшаем длительность ---
|
||||
eff.turnsLeft--;
|
||||
}
|
||||
eff.justCast = false; // Эффект больше не считается "just cast" после обработки этого хода
|
||||
|
||||
// if (isNatureStrengthEffect) { // Отладочный лог
|
||||
// console.log(`[NATURE_STRENGTH_DEBUG] processEffects for ${ownerState.name}: Effect: ${eff.name}, justCast (after): ${eff.justCast}, turnsLeft (after): ${eff.turnsLeft}`);
|
||||
// }
|
||||
|
||||
// --- Удаление закончившихся эффектов ---
|
||||
// --- Отмечаем для удаления, если длительность закончилась ---
|
||||
if (eff.turnsLeft <= 0) {
|
||||
// if (isNatureStrengthEffect) { // Отладочный лог
|
||||
// console.log(`[NATURE_STRENGTH_DEBUG] processEffects for ${ownerState.name}: Effect ${eff.name} REMOVED because turnsLeft is 0.`);
|
||||
// }
|
||||
effectsToRemoveIndexes.push(i);
|
||||
if (addToLogCallback) {
|
||||
addToLogCallback(`Эффект "${eff.name}" на ${ownerName} закончился.`, configToUse.LOG_TYPE_EFFECT);
|
||||
@ -74,37 +90,57 @@ function processEffects(effectsArray, ownerState, ownerBaseStats, ownerId, curre
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем эффекты с конца, чтобы не нарушить индексы
|
||||
for (let i = effectsToRemoveIndexes.length - 1; i >= 0; i--) {
|
||||
effectsArray.splice(effectsToRemoveIndexes[i], 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Обрабатывает отсчет для отключенных (заглушенных) способностей игрока. */
|
||||
/**
|
||||
* Обрабатывает отсчет для отключенных (заглушенных) способностей игрока в конце его хода.
|
||||
* Длительность заглушения уменьшается на 1.
|
||||
* @param {Array<object>} disabledAbilitiesArray - Массив объектов заглушенных способностей.
|
||||
* @param {Array<object>} characterAbilities - Полный список способностей персонажа (для получения имени).
|
||||
* @param {string} characterName - Имя персонажа (для лога).
|
||||
* @param {function} addToLogCallback - Функция для добавления лога.
|
||||
* @returns {void} - Модифицирует disabledAbilitiesArray напрямую.
|
||||
*/
|
||||
function processDisabledAbilities(disabledAbilitiesArray, characterAbilities, characterName, addToLogCallback) {
|
||||
if (!disabledAbilitiesArray || disabledAbilitiesArray.length === 0) return;
|
||||
const stillDisabled = [];
|
||||
disabledAbilitiesArray.forEach(dis => {
|
||||
dis.turnsLeft--;
|
||||
dis.turnsLeft--; // Уменьшаем длительность заглушения
|
||||
if (dis.turnsLeft > 0) {
|
||||
stillDisabled.push(dis);
|
||||
} else {
|
||||
if (addToLogCallback) {
|
||||
const ability = characterAbilities.find(ab => ab.id === dis.abilityId);
|
||||
// Проверка на заглушающий эффект тоже должна быть удалена из activeEffects в processEffects
|
||||
// Здесь мы только обрабатываем список disabledAbilities, удаляя запись
|
||||
if (ability) addToLogCallback(`Способность ${characterName} "${ability.name}" больше не заглушена!`, GAME_CONFIG.LOG_TYPE_INFO);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Очищаем исходный массив и добавляем только те, что еще активны
|
||||
disabledAbilitiesArray.length = 0;
|
||||
disabledAbilitiesArray.push(...stillDisabled);
|
||||
}
|
||||
|
||||
/** Обрабатывает отсчет кулдаунов для способностей. */
|
||||
/**
|
||||
* Обрабатывает отсчет общих кулдаунов для способностей в конце хода.
|
||||
* Длительность кулдауна уменьшается на 1.
|
||||
* @param {object} cooldownsObject - Объект с кулдаунами способностей ({ abilityId: turnsLeft }).
|
||||
* @param {Array<object>} abilitiesList - Полный список способностей персонажа (для получения имени).
|
||||
* @param {string} ownerName - Имя персонажа (для лога).
|
||||
* @param {function} addToLogCallback - Функция для добавления лога.
|
||||
* @returns {void} - Модифицирует cooldownsObject напрямую.
|
||||
*/
|
||||
function processPlayerAbilityCooldowns(cooldownsObject, abilitiesList, ownerName, addToLogCallback) {
|
||||
if (!cooldownsObject || !abilitiesList) return;
|
||||
for (const abilityId in cooldownsObject) {
|
||||
if (cooldownsObject.hasOwnProperty(abilityId) && cooldownsObject[abilityId] > 0) {
|
||||
cooldownsObject[abilityId]--;
|
||||
cooldownsObject[abilityId]--; // Уменьшаем кулдаун
|
||||
if (cooldownsObject[abilityId] === 0) {
|
||||
const ability = abilitiesList.find(ab => ab.id === abilityId);
|
||||
if (ability && addToLogCallback) {
|
||||
@ -115,215 +151,334 @@ function processPlayerAbilityCooldowns(cooldownsObject, abilitiesList, ownerName
|
||||
}
|
||||
}
|
||||
|
||||
/** Обновляет статус 'isBlocking' на основе активных эффектов. */
|
||||
/**
|
||||
* Обновляет статус 'isBlocking' на основе активных эффектов.
|
||||
* @param {object} fighterState - Состояние бойца.
|
||||
* @returns {void} - Модифицирует fighterState.isBlocking.
|
||||
*/
|
||||
function updateBlockingStatus(fighterState) {
|
||||
if (!fighterState) return;
|
||||
// Боец считается блокирующим, если у него есть активный эффект, дающий блок (grantsBlock: true) с turnsLeft > 0
|
||||
fighterState.isBlocking = fighterState.activeEffects.some(eff => eff.grantsBlock && eff.turnsLeft > 0);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Выбирает подходящую насмешку для Елены.
|
||||
* Получает случайную насмешку из системы насмешек для определенного персонажа.
|
||||
* Ищет фразу в gameData.tauntSystem[speakerCharacterKey][opponentCharacterKey][trigger][context].
|
||||
* @param {string} speakerCharacterKey - Ключ персонажа, который произносит насмешку ('elena' или 'almagest' или 'balard').
|
||||
* @param {string} trigger - Тип события, вызвавшего насмешку (например, 'selfCastAbility', 'onOpponentAction', 'battleStart', 'basicAttack', 'opponentNearDefeatCheck').
|
||||
* @param {object} context - Дополнительный контекст (например, { abilityId: 'fireball' }, { outcome: 'success' }).
|
||||
* @param {object} configToUse - Конфигурационный объект игры.
|
||||
* @param {object} gameDataForLogic - Полный объект gameData.
|
||||
* @param {object} currentGameState - Текущее состояние игры.
|
||||
* @returns {string} Текст насмешки или "(Молчание)".
|
||||
*/
|
||||
function getElenaTaunt(trigger, context = {}, configToUse, gameDataForLogic = gameData, currentGameState) {
|
||||
if (!currentGameState || !currentGameState.player || currentGameState.player.characterKey !== 'elena') {
|
||||
function getRandomTaunt(speakerCharacterKey, trigger, context = {}, configToUse, gameDataForLogic = gameData, currentGameState) {
|
||||
// Проверяем наличие системы насмешек для говорящего персонажа
|
||||
const speakerTauntSystem = gameDataForLogic?.tauntSystem?.[speakerCharacterKey];
|
||||
if (!speakerTauntSystem) return "(Молчание)"; // Нет насмешек для этого персонажа
|
||||
|
||||
// Определяем противника, чтобы выбрать соответствующую ветку насмешек
|
||||
// Для этого нужно найти в gameState, кто из player/opponent имеет characterKey говорящего,
|
||||
// и взять characterKey другого.
|
||||
const speakerRole = currentGameState?.player?.characterKey === speakerCharacterKey ?
|
||||
GAME_CONFIG.PLAYER_ID :
|
||||
(currentGameState?.opponent?.characterKey === speakerCharacterKey ?
|
||||
GAME_CONFIG.OPPONENT_ID : null);
|
||||
|
||||
if (speakerRole === null) {
|
||||
console.warn(`getRandomTaunt: Speaker character key "${speakerCharacterKey}" not found in current game state roles.`);
|
||||
return "(Молчание)";
|
||||
}
|
||||
const opponentKey = currentGameState.opponent.characterKey;
|
||||
const tauntSystem = gameDataForLogic?.elenaTauntSystem;
|
||||
const tauntBranch = opponentKey === 'balard' ? tauntSystem?.aiBalard : tauntSystem?.pvpAlmagest;
|
||||
if (!tauntBranch) return "(Молчание)";
|
||||
|
||||
const opponentRole = speakerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
|
||||
const opponentCharacterKey = currentGameState?.[opponentRole]?.characterKey;
|
||||
|
||||
const tauntBranch = speakerTauntSystem[opponentCharacterKey];
|
||||
if (!tauntBranch) {
|
||||
// console.warn(`getRandomTaunt: No taunt branch found for speaker "${speakerCharacterKey}" against opponent "${opponentCharacterKey}".`);
|
||||
return "(Молчание)"; // Нет насмешек против этого оппонента
|
||||
}
|
||||
|
||||
|
||||
let potentialTaunts = [];
|
||||
const opponentHpPerc = (currentGameState.opponent.currentHp / currentGameState.opponent.maxHp) * 100;
|
||||
const isOpponentLowHpForDomination = opponentKey === 'balard' && opponentHpPerc <= configToUse.PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT;
|
||||
const isOpponentNearDefeat = opponentHpPerc < 20;
|
||||
|
||||
if (trigger === 'opponentNearDefeatCheck' && isOpponentNearDefeat && tauntBranch.onBattleState?.opponentNearDefeat) {
|
||||
potentialTaunts = tauntBranch.onBattleState.opponentNearDefeat;
|
||||
// Навигация по структуре tauntSystem в зависимости от триггера и контекста
|
||||
if (trigger === 'battleStart') {
|
||||
potentialTaunts = tauntBranch.onBattleState?.start;
|
||||
}
|
||||
else if (trigger === 'opponentAction' && context.abilityId) {
|
||||
else if (trigger === 'opponentNearDefeatCheck') { // Проверка на низкое HP противника для специальных фраз
|
||||
const opponentState = currentGameState?.[opponentRole]; // Состояние противника для проверки HP
|
||||
// Проверяем, что состояние оппонента существует и его HP ниже порога (например, 20%)
|
||||
if (opponentState && opponentState.maxHp > 0 && opponentState.currentHp / opponentState.maxHp < 0.20) {
|
||||
potentialTaunts = tauntBranch.onBattleState?.opponentNearDefeat;
|
||||
}
|
||||
}
|
||||
else if (trigger === 'selfCastAbility' && context.abilityId) {
|
||||
potentialTaunts = tauntBranch.selfCastAbility?.[context.abilityId];
|
||||
}
|
||||
else if (trigger === 'basicAttack' && tauntBranch.basicAttack) {
|
||||
const opponentState = currentGameState?.[opponentRole]; // Состояние противника
|
||||
// Специальная логика для базовой атаки Елены против Баларда (милосердие/доминирование)
|
||||
if (speakerCharacterKey === 'elena' && opponentCharacterKey === 'balard' && opponentState) {
|
||||
const opponentHpPerc = (opponentState.currentHp / opponentState.maxHp) * 100;
|
||||
if (opponentHpPerc <= configToUse.PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT) {
|
||||
potentialTaunts = tauntBranch.basicAttack.dominating;
|
||||
} else {
|
||||
potentialTaunts = tauntBranch.basicAttack.merciful;
|
||||
}
|
||||
} else { // Общая логика для PvP или Елена/Балард вне порога
|
||||
potentialTaunts = tauntBranch.basicAttack.general;
|
||||
}
|
||||
}
|
||||
// Реакция на действие противника
|
||||
else if (trigger === 'onOpponentAction' && context.abilityId) {
|
||||
const actionResponses = tauntBranch.onOpponentAction?.[context.abilityId];
|
||||
if (actionResponses) {
|
||||
// Если структура содержит вложенные результаты (например, успех/провал Безмолвия)
|
||||
if (typeof actionResponses === 'object' && !Array.isArray(actionResponses) && context.outcome && context.outcome in actionResponses) {
|
||||
potentialTaunts = actionResponses[context.outcome];
|
||||
potentialTaunts = actionResponses[context.outcome]; // Например, onOpponentAction.silence.success
|
||||
} else if (Array.isArray(actionResponses)) {
|
||||
potentialTaunts = actionResponses;
|
||||
potentialTaunts = actionResponses; // Прямой массив фраз для способности
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (trigger === 'opponentAttackBlocked' && tauntBranch.onOpponentAction?.attackBlocked) {
|
||||
// Реакция на попадание/блок атаки противника
|
||||
// Примечание: Эти триггеры срабатывают, когда по ГОВОРЯЩЕМУ попала атака или он ее заблокировал.
|
||||
// Вызываются из performAttack, где известно, кто атакует и кто защищается.
|
||||
else if (trigger === 'onOpponentAttackBlocked' && tauntBranch.onOpponentAction?.attackBlocked) {
|
||||
potentialTaunts = tauntBranch.onOpponentAction.attackBlocked;
|
||||
}
|
||||
else if (trigger === 'opponentAttackHit' && tauntBranch.onOpponentAction?.attackHits) {
|
||||
else if (trigger === 'onOpponentAttackHit' && tauntBranch.onOpponentAction?.attackHits) {
|
||||
potentialTaunts = tauntBranch.onOpponentAction.attackHits;
|
||||
}
|
||||
else if (trigger === 'playerActionCast' && context.abilityId && tauntBranch.onPlayerCast?.[context.abilityId]) {
|
||||
potentialTaunts = tauntBranch.onPlayerCast[context.abilityId];
|
||||
}
|
||||
else if (trigger === 'playerBasicAttack') {
|
||||
if (isOpponentLowHpForDomination) {
|
||||
const pools = tauntBranch.base?.dominating || {};
|
||||
potentialTaunts = [ ...(pools.creatorVsCreation || []), ...(pools.betrayalOfLight || []), ...(pools.ingratitudeContempt || []), ...(pools.unmakingThreats || []) ];
|
||||
} else if (opponentKey === 'balard' && !isOpponentLowHpForDomination) {
|
||||
potentialTaunts = tauntBranch.base?.mercifulAttack || [];
|
||||
} else {
|
||||
potentialTaunts = tauntBranch.base?.generalAttack || tauntBranch.base?.mercifulAttack || [];
|
||||
}
|
||||
}
|
||||
else if (trigger === 'playerActionGeneral') {
|
||||
if (isOpponentLowHpForDomination) {
|
||||
const pools = tauntBranch.base?.dominating || {};
|
||||
potentialTaunts = [ ...(pools.creatorVsCreation || []), ...(pools.betrayalOfLight || []), ...(pools.ingratitudeContempt || []), ...(pools.unmakingThreats || []) ];
|
||||
} else if (opponentKey === 'balard' && !isOpponentLowHpForDomination) {
|
||||
potentialTaunts = tauntBranch.base?.mercifulCast || [];
|
||||
} else {
|
||||
potentialTaunts = tauntBranch.base?.generalCast || tauntBranch.base?.mercifulCast || [];
|
||||
}
|
||||
}
|
||||
else if (trigger === 'battleStart') {
|
||||
const startTaunts = (opponentKey === 'balard' ? tauntBranch.onBattleState?.startMerciful : tauntBranch.onBattleState?.start);
|
||||
if (startTaunts) potentialTaunts = startTaunts;
|
||||
}
|
||||
|
||||
// Если по прямому триггеру не найдено, возвращаем "(Молчание)".
|
||||
// Можно добавить фоллбэк на общие фразы, если требуется более разговорчивый персонаж.
|
||||
// Например: if ((!potentialTaunts || potentialTaunts.length === 0) && tauntBranch.basicAttack?.general) { potentialTaunts = tauntBranch.basicAttack.general; }
|
||||
|
||||
if (!Array.isArray(potentialTaunts) || potentialTaunts.length === 0) {
|
||||
if (opponentKey === 'balard') {
|
||||
if (isOpponentLowHpForDomination) {
|
||||
const pools = tauntBranch.base?.dominating || {};
|
||||
potentialTaunts = [ ...(pools.creatorVsCreation || []), ...(pools.betrayalOfLight || []), ...(pools.ingratitudeContempt || []), ...(pools.unmakingThreats || []) ];
|
||||
} else {
|
||||
potentialTaunts = [...(tauntBranch.base?.mercifulAttack || []), ...(tauntBranch.base?.mercifulCast || [])];
|
||||
return "(Молчание)"; // Возвращаем молчание, если ничего не найдено
|
||||
}
|
||||
} else {
|
||||
potentialTaunts = [...(tauntBranch.base?.generalAttack || []), ...(tauntBranch.base?.generalCast || [])];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(potentialTaunts) || potentialTaunts.length === 0) return "(Молчание)";
|
||||
return potentialTaunts[Math.floor(Math.random() * potentialTaunts.length)] || "(Молчание)";
|
||||
|
||||
// Возвращаем случайную фразу из найденного массива
|
||||
const selectedTaunt = potentialTaunts[Math.floor(Math.random() * potentialTaunts.length)];
|
||||
return selectedTaunt || "(Молчание)"; // Фоллбэк на "(Молчание)" если массив был пуст после всех проверок
|
||||
}
|
||||
|
||||
|
||||
// --- Основные Игровые Функции ---
|
||||
|
||||
/**
|
||||
* Обрабатывает базовую атаку одного бойца по другому.
|
||||
* @param {object} attackerState - Состояние атакующего бойца.
|
||||
* @param {object} defenderState - Состояние защищающегося бойца.
|
||||
* @param {object} attackerBaseStats - Базовые статы атакующего.
|
||||
* @param {object} defenderBaseStats - Базовые статы защищающегося.
|
||||
* @param {object} currentGameState - Текущее состояние игры (для насмешек).
|
||||
* @param {function} addToLogCallback - Функция для добавления лога.
|
||||
* @param {object} configToUse - Конфигурация игры.
|
||||
* @param {object} gameDataForLogic - Данные игры (для насмешек).
|
||||
*/
|
||||
function performAttack(attackerState, defenderState, attackerBaseStats, defenderBaseStats, currentGameState, addToLogCallback, configToUse, gameDataForLogic = gameData) {
|
||||
// Расчет базового урона с вариацией
|
||||
let damage = Math.floor(attackerBaseStats.attackPower * (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE));
|
||||
let tauntMessagePart = "";
|
||||
let tauntMessagePart = ""; // Переменная для насмешки защищающегося
|
||||
|
||||
// Проверка на блок
|
||||
if (defenderState.isBlocking) {
|
||||
const initialDamage = damage;
|
||||
damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION);
|
||||
if (defenderState.characterKey === 'elena') {
|
||||
const blockTaunt = getElenaTaunt('opponentAttackBlocked', {}, configToUse, gameDataForLogic, currentGameState);
|
||||
// Проверка на насмешку ОТ защищающегося (Елены или Альмагест) при блокировании атаки
|
||||
if (defenderState.characterKey === 'elena' || defenderState.characterKey === 'almagest') {
|
||||
// getRandomTaunt принимает speaker (защищающийся), trigger, context, config, gameData, gameState
|
||||
const blockTaunt = getRandomTaunt(defenderState.characterKey, 'onOpponentAttackBlocked', {}, configToUse, gameDataForLogic, currentGameState);
|
||||
if (blockTaunt !== "(Молчание)") tauntMessagePart = ` (${blockTaunt})`;
|
||||
}
|
||||
if (addToLogCallback) addToLogCallback(`🛡️ ${defenderBaseStats.name} блокирует атаку! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK);
|
||||
|
||||
if (addToLogCallback) addToLogCallback(`🛡️ ${defenderBaseStats.name} блокирует атаку ${attackerBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK);
|
||||
} else {
|
||||
let hitMessage = `${attackerBaseStats.name} атакует ${defenderBaseStats.name}! Наносит ${damage} урона.`;
|
||||
if (defenderState.characterKey === 'elena') {
|
||||
const hitTaunt = getElenaTaunt('opponentAttackHit', {}, configToUse, gameDataForLogic, currentGameState);
|
||||
// Проверка на насмешку ОТ защищающегося (Елены или Альмагест) при попадании атаки
|
||||
if (defenderState.characterKey === 'elena' || defenderState.characterKey === 'almagest') {
|
||||
// getRandomTaunt принимает speaker (защищающийся), trigger, context, config, gameData, gameState
|
||||
const hitTaunt = getRandomTaunt(defenderState.characterKey, 'onOpponentAttackHit', {}, configToUse, gameDataForLogic, currentGameState);
|
||||
if (hitTaunt !== "(Молчание)") hitMessage += ` (${hitTaunt})`;
|
||||
}
|
||||
if (addToLogCallback) addToLogCallback(hitMessage, configToUse.LOG_TYPE_DAMAGE);
|
||||
}
|
||||
defenderState.currentHp = Math.max(0, defenderState.currentHp - damage);
|
||||
// Применяем урон, убеждаемся, что HP не ниже нуля
|
||||
// ИСПРАВЛЕНО: Округляем результат вычитания HP
|
||||
defenderState.currentHp = Math.max(0, Math.round(defenderState.currentHp - damage));
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет эффект способности.
|
||||
* @param {object} ability - Объект способности.
|
||||
* @param {object} casterState - Состояние бойца, применившего способность.
|
||||
* @param {object} targetState - Состояние цели способности.
|
||||
* @param {object} casterBaseStats - Базовые статы кастера.
|
||||
* @param {object} targetBaseStats - Базовые статы цели.
|
||||
* @param {object} currentGameState - Текущее состояние игры (для насмешек).
|
||||
* @param {function} addToLogCallback - Функция для добавления лога.
|
||||
* @param {object} configToUse - Конфигурация игры.
|
||||
* @param {object} gameDataForLogic - Данные игры (для насмешек).
|
||||
*/
|
||||
function applyAbilityEffect(ability, casterState, targetState, casterBaseStats, targetBaseStats, currentGameState, addToLogCallback, configToUse, gameDataForLogic = gameData) {
|
||||
let tauntMessagePart = "";
|
||||
let tauntMessagePart = ""; // Переменная для насмешки, если она связана с результатом эффекта или реакцией цели
|
||||
|
||||
// Проверка на насмешку ОТ цели (Елены или Альмагест), если она попадает под способность противника
|
||||
if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
|
||||
// Триггер 'onOpponentAction' с abilityId противника
|
||||
const reactionTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', { abilityId: ability.id }, configToUse, gameDataForLogic, currentGameState);
|
||||
if (reactionTaunt !== "(Молчание)") tauntMessagePart = ` (${reactionTaunt})`;
|
||||
} else {
|
||||
tauntMessagePart = ""; // Другие персонажи (Балард) не имеют реакционных насмешек такого типа
|
||||
}
|
||||
|
||||
|
||||
switch (ability.type) {
|
||||
case configToUse.ACTION_TYPE_HEAL:
|
||||
const healAmount = Math.floor(ability.power * (configToUse.HEAL_VARIATION_MIN + Math.random() * configToUse.HEAL_VARIATION_RANGE));
|
||||
const actualHeal = Math.min(healAmount, casterBaseStats.maxHp - casterState.currentHp);
|
||||
if (actualHeal > 0) {
|
||||
casterState.currentHp += actualHeal;
|
||||
if (addToLogCallback) addToLogCallback(`💚 ${casterBaseStats.name} восстанавливает ${actualHeal} HP!`, configToUse.LOG_TYPE_HEAL);
|
||||
// ИСПРАВЛЕНО: Округляем результат прибавления HP
|
||||
casterState.currentHp = Math.round(casterState.currentHp + actualHeal);
|
||||
// --- ИЗМЕНЕНИЕ: Добавляем название способности в лог лечения ---
|
||||
if (addToLogCallback) addToLogCallback(`💚 ${casterBaseStats.name} применяет "${ability.name}" и восстанавливает ${actualHeal} HP!${tauntMessagePart}`, configToUse.LOG_TYPE_HEAL);
|
||||
// --- КОНЕЦ ИЗМЕНЕНИЯ ---
|
||||
} else {
|
||||
if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} уже имеет полное здоровье или эффект не дал лечения.`, configToUse.LOG_TYPE_INFO);
|
||||
if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} применяет "${ability.name}", но не получает лечения.${tauntMessagePart}`, configToUse.LOG_TYPE_INFO);
|
||||
}
|
||||
break;
|
||||
|
||||
case configToUse.ACTION_TYPE_DAMAGE:
|
||||
let damage = Math.floor(ability.power * (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE));
|
||||
// Проверка на блок цели
|
||||
if (targetState.isBlocking) {
|
||||
const initialDamage = damage;
|
||||
damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION);
|
||||
if (targetState.characterKey === 'elena') {
|
||||
const blockTaunt = getElenaTaunt('opponentAttackBlocked', {abilityId: ability.id} , configToUse, gameDataForLogic, currentGameState);
|
||||
if (blockTaunt !== "(Молчание)") tauntMessagePart = ` (${blockTaunt})`;
|
||||
// Проверка на насмешку ОТ цели (Елены или Альмагест), если она заблокировала урон от способности - перенесено наверх
|
||||
// if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
|
||||
// const blockTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAttackBlocked', {abilityId: ability.id} , configToUse, gameDataForLogic, currentGameState);
|
||||
// if (blockTaunt !== "(Молчание)") tauntMessagePart = ` (${blockTaunt})`;
|
||||
// }
|
||||
if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует "${ability.name}" от ${casterBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK);
|
||||
}
|
||||
if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует "${ability.name}"! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK);
|
||||
}
|
||||
targetState.currentHp = Math.max(0, targetState.currentHp - damage);
|
||||
// Применяем урон, убеждаемся, что HP не ниже нуля
|
||||
// ИСПРАВЛЕНО: Округляем результат вычитания HP
|
||||
targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damage));
|
||||
if (addToLogCallback && !targetState.isBlocking) {
|
||||
let hitMessage = `💥 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!`;
|
||||
if (targetState.characterKey === 'elena') {
|
||||
const hitTaunt = getElenaTaunt('opponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState);
|
||||
if (hitTaunt !== "(Молчание)") hitMessage += ` (${hitTaunt})`;
|
||||
}
|
||||
let hitMessage = `💥 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!${tauntMessagePart}`;
|
||||
// Проверка на насмешку ОТ цели (Елены или Альмагест), если по ней попала способность - перенесено наверх
|
||||
// if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
|
||||
// const hitTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState);
|
||||
// if (hitTaunt !== "(Молчание)") hitMessage += ` (${hitTaunt})`;
|
||||
// }
|
||||
addToLogCallback(hitMessage, configToUse.LOG_TYPE_DAMAGE);
|
||||
}
|
||||
break;
|
||||
|
||||
case configToUse.ACTION_TYPE_BUFF:
|
||||
// Если бафф уже активен, не применяем его повторно (эта проверка уже есть в gameInstance)
|
||||
// Проверка на .some здесь опциональна, т.к. вызывающий код должен гарантировать уникальность
|
||||
if (!casterState.activeEffects.some(e => e.id === ability.id)) {
|
||||
let effectDescription = ability.description;
|
||||
if (typeof ability.descriptionFunction === 'function') {
|
||||
// Для описания баффа может потребоваться информация о противнике
|
||||
const opponentRole = casterState.id === configToUse.PLAYER_ID ? configToUse.OPPONENT_ID : configToUse.PLAYER_ID;
|
||||
const opponentCurrentState = currentGameState[opponentRole];
|
||||
const opponentDataForDesc = opponentCurrentState ? gameDataForLogic[opponentCurrentState.characterKey + 'BaseStats'] : gameDataForLogic.playerBaseStats; // Фоллбэк
|
||||
// Получаем базовые статы противника, если он определен, для функции описания
|
||||
const opponentDataForDesc = opponentCurrentState?.characterKey ? _getCharacterBaseDataForLogic(opponentCurrentState.characterKey, gameDataForLogic) : null; // ИСПОЛЬЗУЕМ _getCharacterBaseDataForLogic
|
||||
effectDescription = ability.descriptionFunction(configToUse, opponentDataForDesc);
|
||||
}
|
||||
// isDelayed: true используется для эффектов, которые срабатывают ПОСЛЕ следующего действия (например, Сила Природы).
|
||||
// duration: исходная длительность из данных, turnsLeft: сколько ходов осталось (включая текущий, если !justCast)
|
||||
casterState.activeEffects.push({
|
||||
id: ability.id, name: ability.name, description: effectDescription,
|
||||
type: ability.type, turnsLeft: ability.duration,
|
||||
grantsBlock: !!ability.grantsBlock, justCast: !!ability.isDelayed
|
||||
type: ability.type, duration: ability.duration, // Сохраняем исходную длительность для отображения в UI или логики
|
||||
turnsLeft: ability.duration, // Длительность жизни эффекта в ходах владельца
|
||||
grantsBlock: !!ability.grantsBlock,
|
||||
isDelayed: !!ability.isDelayed, // Флаг, что эффект отложенный (срабатывает после действия)
|
||||
justCast: true // Флаг, что эффект только что наложен (для логики processEffects)
|
||||
});
|
||||
if (ability.grantsBlock) updateBlockingStatus(casterState);
|
||||
if (ability.grantsBlock) updateBlockingStatus(casterState); // Обновляем статус блока кастера, если бафф его дает
|
||||
// Насмешки при применении баффа (selfCastAbility) добавляются в GameInstance перед вызовом applyAbilityEffect
|
||||
if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} накладывает эффект "${ability.name}"!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT);
|
||||
} else {
|
||||
if (addToLogCallback) addToLogCallback(`Эффект "${ability.name}" уже активен на ${casterBaseStats.name}!`, configToUse.LOG_TYPE_INFO);
|
||||
// Сообщение "уже активен" отправляется из gameInstance перед вызовом applyAbilityEffect
|
||||
}
|
||||
break;
|
||||
|
||||
case configToUse.ACTION_TYPE_DISABLE:
|
||||
tauntMessagePart = ""; // Сбрасываем перед каждым дизейблом
|
||||
if (targetState.characterKey === 'elena') {
|
||||
const disableTaunt = getElenaTaunt('opponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState);
|
||||
if (disableTaunt !== "(Молчание)") tauntMessagePart = ` (${disableTaunt})`;
|
||||
}
|
||||
case configToUse.ACTION_TYPE_DISABLE: // Безмолвие, Стан и т.п.
|
||||
// Проверка на насмешку ОТ цели (Елены или Альмагест), если она попадает под дизейбл противника - перенесено наверх
|
||||
// if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
|
||||
// const disableTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState);
|
||||
// if (disableTaunt !== "(Молчание)") tauntMessagePart = ` (${disableTaunt})`;
|
||||
// }
|
||||
|
||||
if (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE && casterState.characterKey === 'elena') {
|
||||
const effectId = 'fullSilenceByElena';
|
||||
// Гипнотический взгляд Елены / Раскол Разума Альмагест (полное безмолвие)
|
||||
if (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE || ability.id === configToUse.ABILITY_ID_ALMAGEST_DISABLE) {
|
||||
const effectId = ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE ? 'fullSilenceByElena' : 'fullSilenceByAlmagest';
|
||||
// Не накладываем повторно, если эффект уже есть на цели
|
||||
if (!targetState.activeEffects.some(e => e.id === effectId)) {
|
||||
targetState.activeEffects.push({
|
||||
id: effectId, name: ability.name, description: ability.description,
|
||||
type: ability.type, turnsLeft: ability.effectDuration, power: ability.power, isFullSilence: true
|
||||
type: ability.type, duration: ability.effectDuration, turnsLeft: ability.effectDuration, // Длительность в ходах цели
|
||||
power: ability.power, // Урон от эффекта (применяется в конце хода цели)
|
||||
isFullSilence: true,
|
||||
justCast: true // Эффект только что наложен
|
||||
});
|
||||
if (addToLogCallback) addToLogCallback(`🌀 ${casterBaseStats.name} применяет "${ability.name}"! Способности ${targetBaseStats.name} заблокированы на ${ability.effectDuration} хода, и он(а) получает урон!`, configToUse.LOG_TYPE_EFFECT);
|
||||
if (addToLogCallback) addToLogCallback(`🌀 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}! Способности ${targetBaseStats.name} заблокированы на ${ability.effectDuration} хода, и он(а) получает урон!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT);
|
||||
} else {
|
||||
if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO);
|
||||
}
|
||||
}
|
||||
// Эхо Безмолвия Баларда (заглушает случайную абилку)
|
||||
else if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && casterState.characterKey === 'balard') {
|
||||
const success = Math.random() < configToUse.SILENCE_SUCCESS_RATE;
|
||||
const silenceOutcome = success ? 'success' : 'fail';
|
||||
const specificSilenceTaunt = getElenaTaunt('opponentAction', { abilityId: ability.id, outcome: silenceOutcome }, configToUse, gameDataForLogic, currentGameState);
|
||||
tauntMessagePart = (specificSilenceTaunt !== "(Молчание)") ? ` (${specificSilenceTaunt})` : "";
|
||||
// Реакция цели (Елены) на успех/провал безмолвия Баларда - перенесено наверх, но с context.outcome
|
||||
// if (targetState.characterKey === 'elena') { // Балард применяет это только на Елену
|
||||
// const specificSilenceTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', { abilityId: ability.id, outcome: silenceOutcome }, configToUse, gameDataForLogic, currentGameState);
|
||||
// tauntMessagePart = (specificSilenceTaunt !== "(Молчание)") ? ` (${specificSilenceTaunt})` : "";
|
||||
// } else {
|
||||
// tauntMessagePart = ""; // Другие персонажи не реагируют на Безмолвие Баларда
|
||||
// }
|
||||
|
||||
// Нужно получить насмешку с outcome здесь, так как она зависит от результата броска шанса
|
||||
// Временно сохраняем общую насмешку и получаем специфичную
|
||||
let specificSilenceTaunt = "(Молчание)";
|
||||
if (targetState.characterKey === 'elena') { // Балард применяет это только на Елену
|
||||
specificSilenceTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', { abilityId: ability.id, outcome: silenceOutcome }, configToUse, gameDataForLogic, currentGameState);
|
||||
}
|
||||
tauntMessagePart = (specificSilenceTaunt !== "(Молчание)") ? ` (${specificSilenceTaunt})` : tauntMessagePart; // Используем специфичную, если найдена, иначе общую (хотя общая для этого абилки вряд ли есть)
|
||||
|
||||
|
||||
if (success) {
|
||||
const targetAbilities = gameDataForLogic.playerAbilities;
|
||||
const targetAbilities = _getCharacterAbilitiesForLogic(targetState.characterKey, gameDataForLogic); // Глушим абилки цели
|
||||
// Фильтруем способности, которые еще не заглушены этим типом безмолвия
|
||||
const availableAbilities = targetAbilities.filter(pa =>
|
||||
!targetState.disabledAbilities?.some(d => d.abilityId === pa.id) &&
|
||||
!targetState.activeEffects?.some(eff => eff.id === `playerSilencedOn_${pa.id}`)
|
||||
!targetState.activeEffects?.some(eff => eff.id === `playerSilencedOn_${pa.id}`) // Проверка на эффект заглушения для UI/ProcessEffects
|
||||
);
|
||||
|
||||
if (availableAbilities.length > 0) {
|
||||
const abilityToSilence = availableAbilities[Math.floor(Math.random() * availableAbilities.length)];
|
||||
const turns = configToUse.SILENCE_DURATION;
|
||||
targetState.disabledAbilities.push({ abilityId: abilityToSilence.id, turnsLeft: turns + 1 });
|
||||
const turns = configToUse.SILENCE_DURATION; // Длительность из конфига (в ходах цели)
|
||||
// Добавляем запись о заглушенной способности в disabledAbilities цели
|
||||
targetState.disabledAbilities.push({ abilityId: abilityToSilence.id, turnsLeft: turns + 1 }); // +1, т.к. длительность уменьшается в конце хода цели
|
||||
// Добавляем эффект заглушения в activeEffects цели (для UI и ProcessEffects)
|
||||
const silenceEffectIdOnPlayer = `playerSilencedOn_${abilityToSilence.id}`;
|
||||
targetState.activeEffects.push({
|
||||
id: silenceEffectIdOnPlayer, name: `Безмолвие: ${abilityToSilence.name}`,
|
||||
description: `Способность "${abilityToSilence.name}" временно недоступна.`,
|
||||
type: configToUse.ACTION_TYPE_DISABLE, turnsLeft: turns + 1
|
||||
type: configToUse.ACTION_TYPE_DISABLE, sourceAbilityId: ability.id, // Добавлено sourceAbilityId
|
||||
duration: turns, turnsLeft: turns + 1,
|
||||
justCast: true // Эффект только что наложен
|
||||
});
|
||||
if (addToLogCallback) addToLogCallback(`🔇 Эхо Безмолвия! "${abilityToSilence.name}" Елены заблокировано!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT);
|
||||
if (addToLogCallback) addToLogCallback(`🔇 Эхо Безмолвия! "${abilityToSilence.name}" ${targetBaseStats.name} заблокировано на ${turns} хода!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT);
|
||||
} else {
|
||||
if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается наложить Безмолвие, но у ${targetBaseStats.name} нечего глушить!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO);
|
||||
}
|
||||
@ -331,74 +486,74 @@ function applyAbilityEffect(ability, casterState, targetState, casterBaseStats,
|
||||
if (addToLogCallback) addToLogCallback(`💨 Попытка ${casterBaseStats.name} наложить Безмолвие на ${targetBaseStats.name} провалилась!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO);
|
||||
}
|
||||
}
|
||||
else if (ability.id === configToUse.ABILITY_ID_ALMAGEST_DISABLE && casterState.characterKey === 'almagest') {
|
||||
const effectId = 'fullSilenceByAlmagest';
|
||||
if (!targetState.activeEffects.some(e => e.id === effectId)) {
|
||||
targetState.activeEffects.push({
|
||||
id: effectId, name: ability.name, description: ability.description,
|
||||
type: ability.type, turnsLeft: ability.effectDuration, power: ability.power, isFullSilence: true
|
||||
});
|
||||
if (addToLogCallback) addToLogCallback(`🧠 ${casterBaseStats.name} применяет "${ability.name}"! Способности ${targetBaseStats.name} заблокированы на ${ability.effectDuration} хода, и он(а) получает урон!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case configToUse.ACTION_TYPE_DEBUFF:
|
||||
tauntMessagePart = "";
|
||||
if (targetState.characterKey === 'elena') {
|
||||
const debuffTaunt = getElenaTaunt('opponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState);
|
||||
if (debuffTaunt !== "(Молчание)") tauntMessagePart = ` (${debuffTaunt})`;
|
||||
}
|
||||
case configToUse.ACTION_TYPE_DEBUFF: // Ослабления, DoT и т.п.
|
||||
// Проверка на насмешку ОТ цели (Елены или Альмагест), если она попадает под дебафф противника - перенесено наверх
|
||||
// if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
|
||||
// const debuffTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState);
|
||||
// if (debuffTaunt !== "(Молчание)") tauntMessagePart = ` (${debuffTaunt})`;
|
||||
// }
|
||||
|
||||
// Печать Слабости Елены / Проклятие Увядания Альмагест (сжигание ресурса)
|
||||
if (ability.id === configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === configToUse.ABILITY_ID_ALMAGEST_DEBUFF) {
|
||||
const effectIdForDebuff = 'effect_' + ability.id;
|
||||
const effectIdForDebuff = 'effect_' + ability.id; // Уникальный ID эффекта на цели
|
||||
// Не накладываем повторно, если эффект уже есть на цели
|
||||
if (!targetState.activeEffects.some(e => e.id === effectIdForDebuff)) {
|
||||
let effectDescription = ability.description;
|
||||
if (typeof ability.descriptionFunction === 'function') {
|
||||
effectDescription = ability.descriptionFunction(configToUse, targetBaseStats);
|
||||
effectDescription = ability.descriptionFunction(configToUse, targetBaseStats); // Описание может зависеть от цели
|
||||
}
|
||||
targetState.activeEffects.push({
|
||||
id: effectIdForDebuff, name: ability.name, description: effectDescription,
|
||||
type: configToUse.ACTION_TYPE_DEBUFF, sourceAbilityId: ability.id,
|
||||
turnsLeft: ability.effectDuration, power: ability.power,
|
||||
duration: ability.effectDuration, turnsLeft: ability.effectDuration, // Длительность в ходах цели
|
||||
power: ability.power, // Количество сжигаемого ресурса в ход (применяется в конце хода цели)
|
||||
justCast: true // Эффект только что наложен
|
||||
});
|
||||
if (addToLogCallback) addToLogCallback(`📉 ${casterBaseStats.name} накладывает "${ability.name}" на ${targetBaseStats.name}! Ресурс будет сжигаться.${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT);
|
||||
} else {
|
||||
if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case configToUse.ACTION_TYPE_DRAIN:
|
||||
if (casterState.characterKey === 'balard') {
|
||||
case configToUse.ACTION_TYPE_DRAIN: // Похищение Света Баларда (наносит урон, вытягивает ресурс, лечит кастера)
|
||||
if (casterState.characterKey === 'balard') { // Это способность только Баларда
|
||||
let manaDrained = 0; let healthGained = 0; let damageDealtDrain = 0;
|
||||
tauntMessagePart = "";
|
||||
if (targetState.characterKey === 'elena') {
|
||||
const drainTaunt = getElenaTaunt('opponentAction', { abilityId: ability.id }, configToUse, gameDataForLogic, currentGameState);
|
||||
if (drainTaunt !== "(Молчание)") tauntMessagePart = ` (${drainTaunt})`;
|
||||
}
|
||||
// tauntMessagePart уже получена в начале функции
|
||||
|
||||
// Сначала урон от способности
|
||||
if (ability.powerDamage > 0) {
|
||||
let baseDamageDrain = ability.powerDamage;
|
||||
// Проверка на блок цели
|
||||
if (targetState.isBlocking) {
|
||||
baseDamageDrain = Math.floor(baseDamageDrain * configToUse.BLOCK_DAMAGE_REDUCTION);
|
||||
let blockDrainTaunt = "";
|
||||
if (targetState.characterKey === 'elena') {
|
||||
blockDrainTaunt = getElenaTaunt('opponentAttackBlocked', {}, configToUse, gameDataForLogic, currentGameState);
|
||||
// Реакция цели (Елены/Альмагест) на блок урона от дрейна
|
||||
if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') {
|
||||
blockDrainTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAttackBlocked', {}, configToUse, gameDataForLogic, currentGameState);
|
||||
if (blockDrainTaunt !== "(Молчание)") blockDrainTaunt = ` (${blockDrainTaunt})`;
|
||||
}
|
||||
if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует часть урона от "${ability.name}"! Урон снижен до ${baseDamageDrain}.${blockDrainTaunt}`, configToUse.LOG_TYPE_BLOCK);
|
||||
if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует часть урона от "${ability.name}" от ${casterBaseStats.name}! Урон снижен до ${baseDamageDrain}.${blockDrainTaunt}`, configToUse.LOG_TYPE_BLOCK);
|
||||
}
|
||||
damageDealtDrain = Math.max(0, baseDamageDrain);
|
||||
targetState.currentHp = Math.max(0, targetState.currentHp - damageDealtDrain);
|
||||
// ИСПРАВЛЕНО: Округляем результат вычитания HP
|
||||
targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damageDealtDrain));
|
||||
}
|
||||
|
||||
// Затем вытягивание ресурса и лечение кастера
|
||||
const potentialDrain = ability.powerManaDrain;
|
||||
const actualDrain = Math.min(potentialDrain, targetState.currentResource);
|
||||
|
||||
if (actualDrain > 0) {
|
||||
targetState.currentResource -= actualDrain;
|
||||
// ИСПРАВЛЕНО: Округляем результат вычитания ресурса
|
||||
targetState.currentResource = Math.max(0, Math.round(targetState.currentResource - actualDrain));
|
||||
manaDrained = actualDrain;
|
||||
const potentialHeal = Math.floor(manaDrained * ability.powerHealthGainFactor);
|
||||
const actualHealGain = Math.min(potentialHeal, casterBaseStats.maxHp - casterState.currentHp);
|
||||
casterState.currentHp += actualHealGain;
|
||||
// ИСПРАВЛЕНО: Округляем результат прибавления HP
|
||||
casterState.currentHp = Math.round(casterState.currentHp + actualHealGain);
|
||||
healthGained = actualHealGain;
|
||||
}
|
||||
|
||||
@ -407,12 +562,17 @@ function applyAbilityEffect(ability, casterState, targetState, casterBaseStats,
|
||||
if (manaDrained > 0) {
|
||||
logMsgDrain += `Вытягивает ${manaDrained} ${targetBaseStats.resourceName} у ${targetBaseStats.name} и исцеляется на ${healthGained} HP!`;
|
||||
} else if (damageDealtDrain > 0) {
|
||||
logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`;
|
||||
} else {
|
||||
logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения, эффект не сработал!`;
|
||||
logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; // Урон прошел, но ресурс не вытянулся
|
||||
} else { // Ни урона, ни вытягивания ресурса
|
||||
// ИСПРАВЛЕНО: targetBaseStats.resourceName -> targetState.resourceName (или defenderBaseStats.resourceName, если он передается)
|
||||
logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; // Оставляем targetBaseStats.resourceName, т.к. он точнее для лога
|
||||
// Если урон был 0, и ресурса нет, можно уточнить лог
|
||||
if(damageDealtDrain === 0 && potentialDrain > 0) logMsgDrain += ` Урон не прошел или равен нулю, ресурс не похищен.`;
|
||||
}
|
||||
logMsgDrain += tauntMessagePart;
|
||||
logMsgDrain += tauntMessagePart; // Добавляем насмешку цели, если была
|
||||
if (addToLogCallback) addToLogCallback(logMsgDrain, manaDrained > 0 || damageDealtDrain > 0 ? configToUse.LOG_TYPE_DAMAGE : configToUse.LOG_TYPE_INFO);
|
||||
} else {
|
||||
console.warn(`applyAbilityEffect: Drain type ability ${ability?.name} used by non-Balard character ${casterState.characterKey}`);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -421,88 +581,145 @@ function applyAbilityEffect(ability, casterState, targetState, casterBaseStats,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Логика принятия решения для AI (Балард).
|
||||
* @param {object} currentGameState - Текущее состояние игры.
|
||||
* @param {object} gameDataForLogic - Данные игры.
|
||||
* @param {object} configToUse - Конфигурация игры.
|
||||
* @param {function} addToLogCallback - Функция для добавления лога.
|
||||
* @returns {object} Объект с действием AI ({ actionType: 'attack' | 'ability', ability?: object }).
|
||||
*/
|
||||
function decideAiAction(currentGameState, gameDataForLogic = gameData, configToUse, addToLogCallback) {
|
||||
const opponentState = currentGameState.opponent;
|
||||
const playerState = currentGameState.player;
|
||||
const opponentState = currentGameState.opponent; // AI Балард всегда в слоте opponent
|
||||
const playerState = currentGameState.player; // Игрок всегда в слоте player (в AI режиме)
|
||||
|
||||
// Убеждаемся, что это AI Балард
|
||||
if (opponentState.characterKey !== 'balard') {
|
||||
console.warn("[AI DEBUG] decideAiAction called for non-Balard opponent. This should not happen.");
|
||||
return { actionType: 'pass', logMessage: { message: `${opponentState.name} (не AI) пропускает ход.`, type: configToUse.LOG_TYPE_INFO } };
|
||||
}
|
||||
|
||||
const isBalardFullySilencedByElena = opponentState.activeEffects.some(
|
||||
eff => eff.id === 'fullSilenceByElena' && eff.turnsLeft > 0
|
||||
// Проверка полного безмолвия Баларда (от Гипнотического Взгляда Елены или Раскола Разума Альмагест)
|
||||
const isBalardFullySilenced = opponentState.activeEffects.some(
|
||||
eff => eff.isFullSilence && eff.turnsLeft > 0
|
||||
);
|
||||
|
||||
if (isBalardFullySilencedByElena) {
|
||||
if (addToLogCallback) addToLogCallback(`😵 ${opponentState.name} под действием "Гипнотического взгляда"! Атакует в смятении.`, configToUse.LOG_TYPE_EFFECT);
|
||||
if (isBalardFullySilenced) {
|
||||
// AI под полным безмолвием просто атакует
|
||||
// Лог о безмолвии и атаке в смятении добавляется в processAiTurn перед вызовом performAttack.
|
||||
// decideAiAction просто возвращает действие.
|
||||
return { actionType: 'attack' };
|
||||
}
|
||||
|
||||
const availableActions = [];
|
||||
const opponentAbilities = gameDataForLogic.opponentAbilities;
|
||||
const opponentAbilities = gameDataForLogic.opponentAbilities; // Способности Баларда
|
||||
|
||||
// Проверяем доступность способностей AI и добавляем их в список возможных действий с весом
|
||||
// Вес определяет приоритет: выше вес -> выше шанс выбора (после сортировки)
|
||||
|
||||
const healAbility = opponentAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_HEAL);
|
||||
if (healAbility && opponentState.currentResource >= healAbility.cost &&
|
||||
(opponentState.abilityCooldowns?.[healAbility.id] || 0) <= 0 && // Проверка общего КД (хотя у Баларда могут быть только спец. КД)
|
||||
healAbility.condition(opponentState, playerState, currentGameState, configToUse)) {
|
||||
availableActions.push({ weight: 80, type: 'ability', ability: healAbility, requiresSuccessCheck: true, successRate: healAbility.successRate });
|
||||
}
|
||||
|
||||
const silenceAbility = opponentAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_SILENCE);
|
||||
if (silenceAbility && opponentState.currentResource >= silenceAbility.cost &&
|
||||
(opponentState.silenceCooldownTurns === undefined || opponentState.silenceCooldownTurns <= 0) &&
|
||||
(!opponentState.abilityCooldowns || opponentState.abilityCooldowns[silenceAbility.id] === undefined || opponentState.abilityCooldowns[silenceAbility.id] <=0) &&
|
||||
(opponentState.silenceCooldownTurns === undefined || opponentState.silenceCooldownTurns <= 0) && // Проверка спец. КД безмолвия
|
||||
(opponentState.abilityCooldowns?.[silenceAbility.id] || 0) <= 0 && // Проверка общего КД
|
||||
silenceAbility.condition(opponentState, playerState, currentGameState, configToUse)) {
|
||||
const playerHpPercent = (playerState.currentHp / playerState.maxHp) * 100;
|
||||
if (playerHpPercent > (configToUse.PLAYER_HP_BLEED_THRESHOLD_PERCENT || 40)) {
|
||||
// Балард предпочитает безмолвие, если HP Елены не слишком низкое (позволяет ей лечиться, чтобы игра длилась дольше)
|
||||
if (playerHpPercent > (configToUse.PLAYER_HP_BLEED_THRESHOLD_PERCENT || 60)) { // Используем порог для текстов Елены как пример
|
||||
availableActions.push({ weight: 60, type: 'ability', ability: silenceAbility, requiresSuccessCheck: true, successRate: configToUse.SILENCE_SUCCESS_RATE });
|
||||
}
|
||||
}
|
||||
|
||||
const drainAbility = opponentAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN);
|
||||
if (drainAbility && opponentState.currentResource >= drainAbility.cost &&
|
||||
(opponentState.manaDrainCooldownTurns === undefined || opponentState.manaDrainCooldownTurns <= 0) &&
|
||||
(!opponentState.abilityCooldowns || opponentState.abilityCooldowns[drainAbility.id] === undefined || opponentState.abilityCooldowns[drainAbility.id] <=0) &&
|
||||
(opponentState.manaDrainCooldownTurns === undefined || opponentState.manaDrainCooldownTurns <= 0) && // Проверка спец. КД дрейна
|
||||
(opponentState.abilityCooldowns?.[drainAbility.id] || 0) <= 0 && // Проверка общего КД
|
||||
drainAbility.condition(opponentState, playerState, currentGameState, configToUse)) {
|
||||
availableActions.push({ weight: 50, type: 'ability', ability: drainAbility });
|
||||
}
|
||||
|
||||
// Базовая атака - всегда доступна как запасной вариант с низким весом
|
||||
availableActions.push({ weight: 30, type: 'attack' });
|
||||
|
||||
// Если по какой-то причине список доступных действий пуст (не должно быть, т.к. атака всегда есть)
|
||||
if (availableActions.length === 0) {
|
||||
return { actionType: 'pass', logMessage: { message: `${opponentState.name} не может совершить действие.`, type: configToUse.LOG_TYPE_INFO } };
|
||||
}
|
||||
|
||||
// Сортируем действия по весу в порядке убывания
|
||||
availableActions.sort((a, b) => b.weight - a.weight);
|
||||
|
||||
// Перебираем действия в порядке приоритета и выбираем первое возможное
|
||||
for (const action of availableActions) {
|
||||
if (action.type === 'ability') {
|
||||
// Если способность требует проверки успеха (например, Безмолвие Баларда)
|
||||
if (action.requiresSuccessCheck) {
|
||||
if (Math.random() < action.successRate) {
|
||||
return { actionType: action.type, ability: action.ability };
|
||||
// Успех, добавляем лог о попытке (чтобы было видно, что AI пытался)
|
||||
if (addToLogCallback) addToLogCallback(`⭐ ${opponentState.name} пытается использовать "${action.ability.name}"...`, configToUse.LOG_TYPE_INFO);
|
||||
return { actionType: action.type, ability: action.ability }; // Успех, выбираем эту способность
|
||||
} else {
|
||||
if (addToLogCallback) addToLogCallback(`💨 ${opponentState.name} пытается использовать "${action.ability.name}", но терпит неудачу!`, configToUse.LOG_TYPE_INFO);
|
||||
continue;
|
||||
// Провал, добавляем лог о провале и переходим к следующему возможному действию в цикле
|
||||
if (addToLogCallback) addToLogCallback(`💨 Попытка ${opponentState.name} использовать "${action.ability.name}" провалилась!`, configToUse.LOG_TYPE_INFO);
|
||||
continue; // Пробуем следующее действие в списке
|
||||
}
|
||||
} else {
|
||||
// Нет проверки успеха, добавляем лог о попытке и выбираем способность
|
||||
if (addToLogCallback) addToLogCallback(`⭐ ${opponentState.name} использует "${action.ability.name}"...`, configToUse.LOG_TYPE_INFO);
|
||||
return { actionType: action.type, ability: action.ability };
|
||||
}
|
||||
}
|
||||
} else if (action.type === 'attack') {
|
||||
// Атака - всегда возможна (если нет полного безмолвия, проверено выше)
|
||||
if (addToLogCallback) addToLogCallback(`🦶 ${opponentState.name} готовится к атаке...`, configToUse.LOG_TYPE_INFO);
|
||||
return { actionType: 'attack' };
|
||||
}
|
||||
// 'pass' не должен быть в доступных действиях, если атака всегда доступна
|
||||
}
|
||||
|
||||
// Если все попытки выбрать способность или атаку провалились (очень маловероятно, если атака всегда в списке), пропуск хода
|
||||
console.warn("[AI DEBUG] AI failed to select any action. Defaulting to pass.");
|
||||
return { actionType: 'pass', logMessage: { message: `${opponentState.name} не смог выбрать подходящее действие. Пропускает ход.`, type: configToUse.LOG_TYPE_INFO } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Внутренняя проверка условий конца игры (основано на HP).
|
||||
* @param {object} currentGameState - Текущее состояние игры.
|
||||
* @param {object} configToUse - Конфигурация игры.
|
||||
* @param {object} gameDataForLogic - Данные игры.
|
||||
* @returns {boolean} true, если игра окончена, иначе false.
|
||||
*/
|
||||
function checkGameOverInternal(currentGameState, configToUse, gameDataForLogic = gameData) {
|
||||
// Проверка на конец игры происходит только если gameState существует и игра еще не помечена как оконченная
|
||||
if (!currentGameState || currentGameState.isGameOver) return currentGameState ? currentGameState.isGameOver : true;
|
||||
|
||||
// Убеждаемся, что оба бойца определены в gameState и не являются плейсхолдерами
|
||||
// Проверка maxHp > 0 в gameState.opponent гарантирует, что оппонент не плейсхолдер
|
||||
if (!currentGameState.player || !currentGameState.opponent || currentGameState.opponent.maxHp <= 0) {
|
||||
// Если один из бойцов не готов (например, PvP игра ожидает второго игрока), игра не может закончиться по HP
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
const playerDead = currentGameState.player.currentHp <= 0;
|
||||
const opponentDead = currentGameState.opponent.currentHp <= 0;
|
||||
|
||||
// Игра окончена, если один или оба бойца мертвы
|
||||
return playerDead || opponentDead;
|
||||
}
|
||||
|
||||
// Экспортируем все функции, которые используются в других модулях
|
||||
module.exports = {
|
||||
processEffects,
|
||||
processDisabledAbilities,
|
||||
processPlayerAbilityCooldowns,
|
||||
updateBlockingStatus,
|
||||
getElenaTaunt,
|
||||
getRandomTaunt, // Экспортируем переименованную функцию
|
||||
performAttack,
|
||||
applyAbilityEffect,
|
||||
decideAiAction,
|
||||
|
@ -1,270 +1,520 @@
|
||||
// /server_modules/gameManager.js
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const GameInstance = require('./gameInstance');
|
||||
const gameData = require('./data'); // Нужен для getAvailablePvPGamesListForClient
|
||||
const { v4: uuidv4 } = require('uuid'); // Убедитесь, что uuidv4 установлен: npm install uuid
|
||||
const GameInstance = require('./gameInstance'); // Убедитесь, что GameInstance экспортируется из gameInstance.js
|
||||
const gameData = require('./data'); // Нужен для getAvailablePvPGamesListForClient и данных персонажей
|
||||
const GAME_CONFIG = require('./config'); // Нужен для GAME_CONFIG.PLAYER_ID и других констант
|
||||
|
||||
class GameManager {
|
||||
constructor(io) {
|
||||
this.io = io;
|
||||
this.games = {}; // { gameId: GameInstance }
|
||||
this.socketToGame = {}; // { socket.id: gameId }
|
||||
this.pendingPvPGames = []; // [gameId] - ID игр, ожидающих второго игрока в PvP
|
||||
this.userToPendingGame = {}; // { userId: gameId } или { socketId: gameId } - для отслеживания созданных ожидающих игр
|
||||
this.io = io; // Ссылка на Socket.IO сервер для широковещательных рассылок
|
||||
this.games = {}; // { gameId: GameInstance } - Все активные или ожидающие игры
|
||||
this.userIdentifierToGameId = {}; // { userId|socketId: gameId } - Какому пользователю какая игра соответствует (более стабильно, чем socket.id)
|
||||
this.pendingPvPGames = []; // [gameId] - ID PvP игр, ожидающих второго игрока
|
||||
|
||||
// Навешиваем обработчик события 'gameOver' на Socket.IO сервер
|
||||
// Это событие исходит от экземпляра GameInstance при завершении игры (по HP или дисконнекту)
|
||||
// Мы слушаем его здесь, чтобы GameManager мог очистить ссылки.
|
||||
// Примечание: Это событие отправляется всем в комнате игры. GameManager слушает его через io.sockets.sockets.on,
|
||||
// но удобнее слушать его на уровне io, если возможно, или добавить специальный emit из GameInstance.
|
||||
// Текущая архитектура (GameInstance напрямую вызывает io.to(...).emit('gameOver', ...)) уже рабочая.
|
||||
// GameManager сам должен отреагировать на завершение, проверяя gameState.isGameOver после каждого действия/хода.
|
||||
// Или GameInstance должен вызвать специальный метод GameManager при gameOver.
|
||||
// Давайте сделаем GameInstance вызывать метод GameManager при gameOver.
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет предыдущую ожидающую игру пользователя, если таковая существует.
|
||||
* Это предотвращает создание множества пустых игр одним пользователем.
|
||||
* @param {string} currentSocketId - ID текущего сокета.
|
||||
* @param {string|number} identifier - userId или socketId пользователя.
|
||||
* @param {string|null} excludeGameId - ID игры, которую НЕ нужно удалять (например, если пользователь присоединяется к своей же игре).
|
||||
*/
|
||||
_removePreviousPendingGames(currentSocketId, identifier, excludeGameId = null) {
|
||||
const keyToUse = identifier || currentSocketId;
|
||||
const oldPendingGameId = this.userToPendingGame[keyToUse];
|
||||
// Ищем игру по идентификатору пользователя
|
||||
const oldPendingGameId = this.userIdentifierToGameId[identifier];
|
||||
|
||||
if (oldPendingGameId && oldPendingGameId !== excludeGameId) {
|
||||
// Проверяем, что нашли игру, она не исключена, и она все еще существует в списке игр
|
||||
if (oldPendingGameId && oldPendingGameId !== excludeGameId && this.games[oldPendingGameId]) {
|
||||
const gameToRemove = this.games[oldPendingGameId];
|
||||
if (gameToRemove && gameToRemove.mode === 'pvp' && gameToRemove.playerCount === 1 && this.pendingPvPGames.includes(oldPendingGameId)) {
|
||||
const playersInOldGame = Object.values(gameToRemove.players);
|
||||
const isOwnerBySocket = playersInOldGame.length === 1 && playersInOldGame[0].socket.id === currentSocketId;
|
||||
const isOwnerByUserId = identifier && gameToRemove.ownerUserId === identifier;
|
||||
// Проверяем, что игра является ожидающей PvP игрой с одним игроком
|
||||
if (gameToRemove.mode === 'pvp' && gameToRemove.playerCount === 1 && this.pendingPvPGames.includes(oldPendingGameId)) {
|
||||
// Проверяем, что этот пользователь является владельцем этой ожидающей игры
|
||||
// Владелец в pendingPvPGames - это всегда тот, кто ее создал (первый игрок в слоте PLAYER_ID)
|
||||
const oldOwnerInfo = Object.values(gameToRemove.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
|
||||
|
||||
if (isOwnerBySocket || isOwnerByUserId) {
|
||||
console.log(`[GameManager] Пользователь ${keyToUse} (сокет: ${currentSocketId}) создал/присоединился к новой игре. Удаляем его предыдущую ожидающую игру: ${oldPendingGameId}`);
|
||||
// Проверяем, что владелец игры существует и его идентификатор совпадает
|
||||
if (oldOwnerInfo && (oldOwnerInfo.identifier === identifier)) {
|
||||
console.log(`[GameManager] Пользователь ${identifier} (сокет: ${currentSocketId}) создал/присоединился к новой игре. Удаляем его предыдущую ожидающую игру: ${oldPendingGameId}`);
|
||||
|
||||
delete this.games[oldPendingGameId];
|
||||
const pendingIndex = this.pendingPvPGames.indexOf(oldPendingGameId);
|
||||
if (pendingIndex > -1) this.pendingPvPGames.splice(pendingIndex, 1);
|
||||
// Используем централизованную функцию очистки
|
||||
this._cleanupGame(oldPendingGameId, 'replaced_by_new_game');
|
||||
|
||||
if (playersInOldGame.length === 1 && this.socketToGame[playersInOldGame[0].socket.id] === oldPendingGameId) {
|
||||
delete this.socketToGame[playersInOldGame[0].socket.id];
|
||||
// Оповещаем клиентов об обновленном списке игр (уже внутри _cleanupGame)
|
||||
// this.broadcastAvailablePvPGames();
|
||||
}
|
||||
delete this.userToPendingGame[keyToUse];
|
||||
|
||||
this.broadcastAvailablePvPGames();
|
||||
}
|
||||
} else if (oldPendingGameId === excludeGameId) {
|
||||
// Это та же игра, к которой игрок присоединяется, ничего не делаем
|
||||
} else {
|
||||
delete this.userToPendingGame[keyToUse];
|
||||
// Если игра не соответствует критериям ожидающей игры, но идентификатор был связан с ней,
|
||||
// это может означать, что игра уже началась или была завершена.
|
||||
// Просто очищаем ссылку, если она не ведет в исключенную игру.
|
||||
// Идентификатор должен был быть очищен из userIdentifierToGameId при старте или завершении игры.
|
||||
// На всякий случай убеждаемся, что мы не удаляем ссылку на игру, к которой только что присоединились.
|
||||
if (this.userIdentifierToGameId[identifier] !== excludeGameId) {
|
||||
console.warn(`[GameManager] Удаление потенциально некорректной ссылки userIdentifierToGameId[${identifier}] на игру ${oldPendingGameId}.`);
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Если oldPendingGameId не найдена, или она равна excludeGameId, ничего не делаем.
|
||||
}
|
||||
|
||||
createGame(socket, mode = 'ai', chosenCharacterKey = 'elena', userId = null) {
|
||||
const identifier = userId || socket.id;
|
||||
|
||||
/**
|
||||
* Создает новую игру.
|
||||
* @param {object} socket - Сокет игрока, создающего игру.
|
||||
* @param {string} [mode='ai'] - Режим игры ('ai' или 'pvp').
|
||||
* @param {string} [chosenCharacterKey='elena'] - Выбранный персонаж для первого игрока в PvP.
|
||||
* @param {string|number} identifier - ID пользователя (userId или socketId).
|
||||
*/
|
||||
createGame(socket, mode = 'ai', chosenCharacterKey = 'elena', identifier) {
|
||||
// Удаляем старые ожидающие игры этого пользователя, прежде чем создавать новую
|
||||
this._removePreviousPendingGames(socket.id, identifier);
|
||||
|
||||
const gameId = uuidv4();
|
||||
const game = new GameInstance(gameId, this.io, mode);
|
||||
if (userId) game.ownerUserId = userId;
|
||||
this.games[gameId] = game;
|
||||
// Проверяем, не находится ли пользователь уже в какой-то игре (активной или ожидающей)
|
||||
// Проверяем наличие ссылки на игру по идентификатору пользователя
|
||||
if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) {
|
||||
console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${this.userIdentifierToGameId[identifier]}. Игнорируем запрос на создание.`);
|
||||
socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' });
|
||||
// Можно попробовать отправить состояние текущей игры пользователю
|
||||
this.handleRequestGameState(socket, identifier);
|
||||
return;
|
||||
}
|
||||
|
||||
// В AI режиме игрок всегда Елена, в PvP - тот, кого выбрали
|
||||
|
||||
const gameId = uuidv4(); // Генерируем уникальный ID для игры
|
||||
// Передаем ссылку на GameManager в GameInstance, чтобы он мог вызвать _notifyGameEnded
|
||||
const game = new GameInstance(gameId, this.io, mode, this); // <-- ПЕРЕДАЕМ GameManager
|
||||
game.ownerIdentifier = identifier; // Сохраняем идентификатор создателя
|
||||
this.games[gameId] = game; // Добавляем игру в список активных игр
|
||||
|
||||
// В AI режиме игрок всегда Елена, в PvP - тот, кого выбрали при создании
|
||||
const charKeyForInstance = (mode === 'pvp') ? chosenCharacterKey : 'elena';
|
||||
|
||||
if (game.addPlayer(socket, charKeyForInstance)) {
|
||||
this.socketToGame[socket.id] = gameId;
|
||||
console.log(`[GameManager] Игра создана: ${gameId} (режим: ${mode}) игроком ${socket.userData?.username || socket.id} (userId: ${userId}, выбран: ${charKeyForInstance})`);
|
||||
// Добавляем игрока в созданный экземпляр игры, передавая идентификатор
|
||||
// GameInstance.addPlayer принимает socket, chosenCharacterKey, identifier
|
||||
if (game.addPlayer(socket, charKeyForInstance, identifier)) {
|
||||
this.userIdentifierToGameId[identifier] = gameId; // Связываем идентификатор пользователя с этой игрой
|
||||
console.log(`[GameManager] Игра создана: ${gameId} (режим: ${mode}) игроком ${identifier} (сокет: ${socket.id}, выбран: ${charKeyForInstance})`);
|
||||
|
||||
const assignedPlayerId = game.players[socket.id]?.id;
|
||||
// Уведомляем игрока, что игра создана, и передаем его технический ID слота
|
||||
const assignedPlayerId = game.players[socket.id]?.id; // ID слота все еще берем из playerInfo по socket.id
|
||||
if (!assignedPlayerId) {
|
||||
delete this.games[gameId]; if(this.socketToGame[socket.id] === gameId) delete this.socketToGame[socket.id];
|
||||
socket.emit('gameError', { message: 'Ошибка сервера при создании игры (не удалось назначить ID игрока).' }); return;
|
||||
// Если по какой-то причине не удалось назначить ID игрока, удаляем игру и отправляем ошибку
|
||||
// Используем централизованную функцию очистки
|
||||
this._cleanupGame(gameId, 'player_add_failed');
|
||||
console.error(`[GameManager] Ошибка при создании игры ${gameId}: Не удалось назначить ID игрока сокету ${socket.id} (идентификатор ${identifier}).`);
|
||||
socket.emit('gameError', { message: 'Ошибка сервера при создании игры.' });
|
||||
return;
|
||||
}
|
||||
socket.emit('gameCreated', { gameId: gameId, mode: mode, yourPlayerId: assignedPlayerId });
|
||||
|
||||
if (mode === 'pvp') {
|
||||
if (!this.pendingPvPGames.includes(gameId)) this.pendingPvPGames.push(gameId);
|
||||
this.userToPendingGame[identifier] = gameId;
|
||||
this.broadcastAvailablePvPGames();
|
||||
}
|
||||
|
||||
// --- Логика старта игры ---
|
||||
// Если игра AI и теперь с 1 игроком, или PvP и теперь с 2 игроками, запускаем ее немедленно
|
||||
if ((game.mode === 'ai' && game.playerCount === 1) || (game.mode === 'pvp' && game.playerCount === 2)) {
|
||||
console.log(`[GameManager] Игра ${gameId} готова к старту. Инициализация и запуск.`);
|
||||
// Инициализируем состояние игры. initializeGame вернет true, если оба бойца определены.
|
||||
const isInitialized = game.initializeGame();
|
||||
if (isInitialized) { // Проверяем, успешно ли инициализировалось состояние
|
||||
game.startGame(); // Запускаем игру
|
||||
} else {
|
||||
delete this.games[gameId];
|
||||
if (this.socketToGame[socket.id] === gameId) delete this.socketToGame[socket.id];
|
||||
// Сообщение об ошибке отправляется из game.addPlayer
|
||||
console.error(`[GameManager] Не удалось запустить игру ${gameId}: initializeGame вернул false или gameState некорректен после инициализации.`);
|
||||
// initializeGame уже должен был добавить ошибку в лог игры и отправить gameError клиентам
|
||||
// Возможно, стоит вызвать cleanupGame здесь при ошибке инициализации
|
||||
this._cleanupGame(gameId, 'initialization_failed');
|
||||
}
|
||||
|
||||
// Если игра PvP и только что заполнилась, удаляем ее из списка ожидающих
|
||||
// Идентификаторы игроков остаются связанными с игрой в userIdentifierToGameId до ее завершения.
|
||||
if (game.mode === 'pvp' && game.playerCount === 2) {
|
||||
const gameIndex = this.pendingPvPGames.indexOf(gameId);
|
||||
if (gameIndex > -1) this.pendingPvPGames.splice(gameIndex, 1);
|
||||
// Связи userIdentifierToGameId[identifier] НЕ УДАЛЯЕМ! Они нужны для активной игры.
|
||||
this.broadcastAvailablePvPGames(); // Обновляем список у всех клиентов
|
||||
}
|
||||
|
||||
|
||||
} else if (mode === 'pvp' && game.playerCount === 1) {
|
||||
// Если игра PvP и ожидает второго игрока, добавляем ее в список ожидающих
|
||||
if (!this.pendingPvPGames.includes(gameId)) {
|
||||
this.pendingPvPGames.push(gameId); // Добавляем ID игры в список ожидающих
|
||||
}
|
||||
// userIdentifierToGameId для создателя уже установлен выше
|
||||
|
||||
// Частичная инициализация gameState для отображения Player 1 на UI ожидания
|
||||
// initializeGame вызывается при playerCount === 1 в GameInstance
|
||||
game.initializeGame();
|
||||
|
||||
this.broadcastAvailablePvPGames(); // Обновляем список у всех
|
||||
}
|
||||
// --- КОНЕЦ Логики старта игры ---
|
||||
|
||||
|
||||
} else {
|
||||
// Если не удалось добавить игрока в GameInstance (например, уже 2 игрока - хотя проверили выше), удаляем игру
|
||||
// Используем централизованную функцию очистки
|
||||
this._cleanupGame(gameId, 'player_add_failed');
|
||||
// GameInstance.addPlayer уже отправил ошибку клиенту
|
||||
console.warn(`[GameManager] Не удалось добавить игрока ${socket.id} (идентификатор ${identifier}) в игру ${gameId}. Игра удалена.`);
|
||||
}
|
||||
}
|
||||
|
||||
joinGame(socket, gameId, userId = null) {
|
||||
const identifier = userId || socket.id;
|
||||
const game = this.games[gameId];
|
||||
/**
|
||||
* Присоединяет игрока к существующей игре по ID.
|
||||
* @param {object} socket - Сокет игрока.
|
||||
* @param {string} gameId - ID игры, к которой нужно присоединиться.
|
||||
* @param {string|number} identifier - ID пользователя (userId).
|
||||
*/
|
||||
joinGame(socket, gameId, identifier) { // В joinGame всегда передается userId, т.к. PvP требует логина
|
||||
const game = this.games[gameId]; // Находим игру по ID
|
||||
|
||||
// Проверки перед присоединением
|
||||
if (!game) { socket.emit('gameError', { message: 'Игра с таким ID не найдена.' }); return; }
|
||||
if (game.mode !== 'pvp') { socket.emit('gameError', { message: 'Эта игра не является PvP игрой.' }); return; }
|
||||
if (game.mode !== 'pvp') { socket.emit('gameError', { message: 'К этой игре нельзя присоединиться как к PvP.' }); return; }
|
||||
if (game.playerCount >= 2) { socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return; }
|
||||
if (game.players[socket.id]) { socket.emit('gameError', { message: 'Вы уже в этой игре.' }); return;}
|
||||
// Проверка, не находится ли пользователь уже в какой-то игре
|
||||
if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]] && this.userIdentifierToGameId[identifier] !== gameId) {
|
||||
console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${this.userIdentifierToGameId[identifier]}. Игнорируем запрос на присоединение.`);
|
||||
socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' });
|
||||
this.handleRequestGameState(socket, identifier); // Попробуем отправить состояние текущей игры
|
||||
return;
|
||||
}
|
||||
if (game.players[socket.id]) { socket.emit('gameError', { message: 'Вы уже в этой игре.' }); return;} // Проверка на повторное присоединение по текущему сокету (хотя userIdentifierToGameId должен это предотвратить)
|
||||
|
||||
// Удаляем старые ожидающие игры этого пользователя, исключая текущую игру, к которой присоединяемся
|
||||
this._removePreviousPendingGames(socket.id, identifier, gameId);
|
||||
|
||||
// addPlayer в GameInstance сам определит персонажа для второго игрока на основе первого
|
||||
if (game.addPlayer(socket)) {
|
||||
this.socketToGame[socket.id] = gameId;
|
||||
console.log(`[GameManager] Игрок ${socket.userData?.username || socket.id} (userId: ${userId}) присоединился к PvP игре ${gameId}`);
|
||||
// GameInstance.addPlayer принимает socket, chosenCharacterKey (null для присоединения), identifier
|
||||
if (game.addPlayer(socket, null, identifier)) { // chosenCharacterKey для присоединяющегося игрока не нужен, передаем null
|
||||
this.userIdentifierToGameId[identifier] = gameId; // Связываем идентификатор пользователя с этой игрой
|
||||
console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) присоединился к PvP игре ${gameId}`);
|
||||
|
||||
// --- Логика старта игры ---
|
||||
// Если игра PvP и теперь с 2 игроками, запускаем ее немедленно
|
||||
if (game.mode === 'pvp' && game.playerCount === 2) {
|
||||
console.log(`[GameManager] Игра ${gameId} готова к старту. Инициализация и запуск.`);
|
||||
// Инициализируем состояние игры. initializeGame вернет true, если оба бойца определены.
|
||||
const isInitialized = game.initializeGame();
|
||||
if (isInitialized) { // Проверяем, успешно ли инициализировалось состояние
|
||||
game.startGame(); // Запускаем игру
|
||||
} else {
|
||||
console.error(`[GameManager] Не удалось запустить игру ${gameId}: initializeGame вернул false или gameState некорректен после инициализации.`);
|
||||
// initializeGame уже должен был добавить ошибку в лог игры и отправить gameError клиентам
|
||||
// Возможно, стоит вызвать cleanupGame здесь при ошибке инициализации
|
||||
this._cleanupGame(gameId, 'initialization_failed');
|
||||
}
|
||||
|
||||
// Если игра PvP и только что заполнилась, удаляем ее из списка ожидающих
|
||||
const gameIndex = this.pendingPvPGames.indexOf(gameId);
|
||||
if (gameIndex > -1) this.pendingPvPGames.splice(gameIndex, 1);
|
||||
|
||||
if (game.ownerUserId && this.userToPendingGame[game.ownerUserId] === gameId) {
|
||||
delete this.userToPendingGame[game.ownerUserId];
|
||||
} else {
|
||||
const firstPlayerSocketId = Object.keys(game.players).find(sId => game.players[sId].id === GAME_CONFIG.PLAYER_ID && game.players[sId].socket.id !== socket.id);
|
||||
if (firstPlayerSocketId && this.userToPendingGame[firstPlayerSocketId] === gameId) {
|
||||
delete this.userToPendingGame[firstPlayerSocketId];
|
||||
// Связи userIdentifierToGameId[identifier] НЕ УДАЛЯЕМ! Они нужны для активной игры.
|
||||
// ownerIdentifier игры (идентификатор создателя) также остается.
|
||||
|
||||
this.broadcastAvailablePvPGames(); // Обновляем список у всех клиентов
|
||||
}
|
||||
}
|
||||
this.broadcastAvailablePvPGames();
|
||||
// --- КОНЕЦ Логики старта игры ---
|
||||
|
||||
|
||||
} else {
|
||||
// Сообщение об ошибке отправляется из game.addPlayer
|
||||
console.warn(`[GameManager] Не удалось добавить игрока ${socket.id} (идентификатор ${identifier}) в игру ${gameId}.`);
|
||||
}
|
||||
}
|
||||
|
||||
findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', userId = null) {
|
||||
const identifier = userId || socket.id;
|
||||
/**
|
||||
* Ищет случайную ожидающую PvP игру и присоединяет игрока к ней.
|
||||
* Если подходящих игр нет, создает новую ожидающую игру.
|
||||
* @param {object} socket - Сокет игрока.
|
||||
* @param {string} [chosenCharacterKeyForCreation='elena'] - Выбранный персонаж, если придется создавать новую игру.
|
||||
* @param {string|number} identifier - ID пользователя (userId).
|
||||
*/
|
||||
findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', identifier) { // В findRandomGame всегда передается userId
|
||||
// Удаляем старые ожидающие игры этого пользователя
|
||||
this._removePreviousPendingGames(socket.id, identifier);
|
||||
|
||||
// Проверяем, не находится ли пользователь уже в какой-то игре
|
||||
if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) {
|
||||
console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${this.userIdentifierToGameId[identifier]}. Игнорируем запрос на поиск.`);
|
||||
socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' });
|
||||
this.handleRequestGameState(socket, identifier); // Попробуем отправить состояние текущей игры
|
||||
return;
|
||||
}
|
||||
|
||||
let gameIdToJoin = null;
|
||||
// Персонаж, которого мы бы хотели видеть у оппонента (зеркальный нашему выбору)
|
||||
// Персонаж, которого мы бы хотели видеть у оппонента (зеркальный нашему выбору для создания)
|
||||
const preferredOpponentKey = chosenCharacterKeyForCreation === 'elena' ? 'almagest' : 'elena';
|
||||
|
||||
// Сначала ищем игру, где первый игрок выбрал "зеркального" персонажа
|
||||
// Ищем свободную игру в списке ожидающих
|
||||
for (const id of this.pendingPvPGames) {
|
||||
const pendingGame = this.games[id];
|
||||
if (pendingGame && pendingGame.playerCount === 1 && pendingGame.mode === 'pvp') {
|
||||
const firstPlayerInfo = Object.values(pendingGame.players)[0];
|
||||
const isMyOwnGame = (userId && pendingGame.ownerUserId === userId) || (firstPlayerInfo.socket.id === socket.id);
|
||||
if (isMyOwnGame) continue;
|
||||
|
||||
// Проверяем, что игра существует, PvP, в ней только 1 игрок и это НЕ игра, которую создал сам текущий пользователь
|
||||
// Игрок не должен присоединяться к игре, которую создал сам.
|
||||
if (pendingGame && pendingGame.mode === 'pvp' && pendingGame.playerCount === 1 && pendingGame.ownerIdentifier !== identifier) {
|
||||
// Нашли потенциальную игру. Проверяем предпочтительного оппонента.
|
||||
const firstPlayerInfo = Object.values(pendingGame.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); // В ожидающей игре всегда 1 игрок, он и есть players[0]
|
||||
if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === preferredOpponentKey) {
|
||||
gameIdToJoin = id; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Если не нашли с предпочтительным оппонентом, ищем любую свободную (не нашу)
|
||||
if (!gameIdToJoin && this.pendingPvPGames.length > 0) {
|
||||
for (const id of this.pendingPvPGames) {
|
||||
const pendingGame = this.games[id];
|
||||
if (pendingGame && pendingGame.playerCount === 1 && pendingGame.mode === 'pvp') {
|
||||
const firstPlayerInfo = Object.values(pendingGame.players)[0];
|
||||
const isMyOwnGame = (userId && pendingGame.ownerUserId === userId) || (firstPlayerInfo.socket.id === socket.id);
|
||||
if (isMyOwnGame) continue;
|
||||
gameIdToJoin = id; break;
|
||||
gameIdToJoin = id; // Нашли игру с предпочтительным оппонентом
|
||||
break; // Выходим из цикла, т.к. нашли лучший вариант
|
||||
}
|
||||
// Если предпочтительного не нашли в этом цикле, сохраняем ID первой попавшейся (не своей) игры
|
||||
if (!gameIdToJoin) gameIdToJoin = id; // Сохраняем, но продолжаем искать предпочтительную
|
||||
}
|
||||
}
|
||||
|
||||
if (gameIdToJoin) {
|
||||
// Присоединяемся к найденной игре. GameInstance.addPlayer сам назначит нужного персонажа второму игроку.
|
||||
this.joinGame(socket, gameIdToJoin, userId);
|
||||
console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) нашел игру ${gameIdToJoin} и присоединяется.`);
|
||||
this.joinGame(socket, gameIdToJoin, identifier); // Используем joinGame, т.к. логика присоединения одинакова
|
||||
} else {
|
||||
// Если свободных игр нет, создаем новую с выбранным персонажем
|
||||
this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, userId);
|
||||
console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) не нашел свободных игр. Создает новую.`);
|
||||
this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, identifier); // Используем createGame
|
||||
// Клиент получит 'gameCreated', а 'noPendingGamesFound' используется для информационного сообщения
|
||||
// userIdentifierToGameId уже обновлен в createGame
|
||||
socket.emit('noPendingGamesFound', {
|
||||
message: 'Свободных PvP игр не найдено. Создана новая игра для вас. Ожидайте противника.',
|
||||
gameId: this.userToPendingGame[identifier], // ID только что созданной игры
|
||||
gameId: this.userIdentifierToGameId[identifier], // ID только что созданной игры
|
||||
yourPlayerId: GAME_CONFIG.PLAYER_ID // При создании всегда PLAYER_ID
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handlePlayerAction(socketId, actionData) {
|
||||
const gameIdFromSocket = this.socketToGame[socketId];
|
||||
const game = this.games[gameIdFromSocket];
|
||||
if (game) {
|
||||
game.processPlayerAction(socketId, actionData);
|
||||
/**
|
||||
* Перенаправляет действие игрока соответствующему экземпляру игры.
|
||||
* @param {string|number} identifier - ID пользователя (userId или socketId).
|
||||
* @param {object} actionData - Данные о действии.
|
||||
*/
|
||||
handlePlayerAction(identifier, actionData) { // Теперь принимаем identifier
|
||||
const gameId = this.userIdentifierToGameId[identifier]; // Находим ID игры по идентификатору пользователя
|
||||
const game = this.games[gameId]; // Находим экземпляр игры
|
||||
|
||||
if (game && game.players) {
|
||||
// Находим текущий сокет ID пользователя в списке игроков этой игры
|
||||
const playerInfo = Object.values(game.players).find(p => p.identifier === identifier);
|
||||
const currentSocketId = playerInfo?.socket?.id;
|
||||
|
||||
if (playerInfo && currentSocketId) {
|
||||
// Проверяем, что сокет с этим ID еще подключен.
|
||||
// Это дополнительная проверка, чтобы не обрабатывать действия от "зомби"-сокетов
|
||||
const actualSocket = this.io.sockets.sockets.get(currentSocketId);
|
||||
|
||||
if (actualSocket && actualSocket.connected) {
|
||||
// Передаем действие экземпляру игры, используя ТЕКУЩИЙ Socket ID
|
||||
game.processPlayerAction(currentSocketId, actionData); // processPlayerAction в GameInstance использует socketId
|
||||
} else {
|
||||
const playerSocket = this.io.sockets.sockets.get(socketId);
|
||||
if (playerSocket) playerSocket.emit('gameError', { message: 'Ошибка: игровая сессия потеряна для этого действия.' });
|
||||
// Если сокет не найден или не подключен, это может быть старое действие от отключившегося сокета
|
||||
console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}), но его текущий сокет (${currentSocketId}) не найден или отключен.`);
|
||||
// Не отправляем ошибку клиенту, так как он, вероятно, уже отключен или переподключается
|
||||
// Клиент получит gameNotFound при следующем запросе состояния или gameError, если игра еще активна
|
||||
}
|
||||
} else {
|
||||
// Игрок не найден в списке players этой игры по идентификатору
|
||||
console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}) для игры ${gameId}, но его запись не найдена в game.players.`);
|
||||
// В таком случае, возможно, состояние userIdentifierToGameId некорректно.
|
||||
// Удаляем некорректную ссылку.
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
// Оповещаем клиента, что игра не найдена (он должен будет запросить состояние)
|
||||
const playerSocket = this.io.sockets.sockets.get(identifier); // Попробуем найти сокет по идентификатору (если он был socket.id)
|
||||
if (!playerSocket && playerInfo?.socket) { // Если не нашли по identifier, попробуем по сокету из playerInfo
|
||||
playerSocket = playerInfo.socket;
|
||||
}
|
||||
if (playerSocket) {
|
||||
playerSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена или завершена.' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Если игра не найдена по userIdentifierToGameId[identifier]
|
||||
console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}), но его игра (ID: ${gameId}) не найдена в GameManager.`);
|
||||
// Удаляем некорректную ссылку
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
// Отправляем gameNotFound клиенту, если можем его найти (по identifier, если это socket.id)
|
||||
const playerSocket = this.io.sockets.sockets.get(identifier);
|
||||
if (playerSocket) {
|
||||
playerSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена или завершена.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(socketId, userId = null) {
|
||||
const identifier = userId || socketId;
|
||||
const gameId = this.socketToGame[socketId];
|
||||
|
||||
if (gameId && this.games[gameId]) {
|
||||
/**
|
||||
* Обрабатывает отключение сокета игрока.
|
||||
* Вызывается из bc.js при событии 'disconnect'.
|
||||
* @param {string} socketId - ID отключившегося сокета.
|
||||
* @param {string|number} identifier - ID пользователя (userId или socketId).
|
||||
*/
|
||||
handleDisconnect(socketId, identifier) { // Принимаем и socketId, и identifier
|
||||
// Ищем игру по идентификатору пользователя (более надежный способ после переподключения)
|
||||
const gameId = this.userIdentifierToGameId[identifier];
|
||||
const game = this.games[gameId];
|
||||
const playerInfo = game.players[socketId];
|
||||
const username = playerInfo?.socket?.userData?.username || socketId;
|
||||
console.log(`[GameManager] Игрок ${username} (socket: ${socketId}, userId: ${userId}) отключился от игры ${gameId}.`);
|
||||
game.removePlayer(socketId);
|
||||
|
||||
// Если игра найдена и в ней есть игрок с этим идентификатором (или сокетом)
|
||||
if (game && game.players) {
|
||||
// Находим информацию об игроке по идентификатору
|
||||
const playerInfo = Object.values(game.players).find(p => p.identifier === identifier);
|
||||
|
||||
if (playerInfo) {
|
||||
console.log(`[GameManager] Игрок ${identifier} (сокет: ${socketId}) отключился. В игре ${gameId}.`);
|
||||
|
||||
// Удаляем игрока из экземпляра игры, передавая Socket ID, который отключился
|
||||
// GameInstance.removePlayer принимает socketId
|
||||
game.removePlayer(socketId); // Передаем socketId для удаления конкретного сокета
|
||||
|
||||
// После удаления игрока из GameInstance, проверяем состояние игры и GameManager
|
||||
if (game.playerCount === 0) {
|
||||
console.log(`[GameManager] Игра ${gameId} пуста и будет удалена (после дисконнекта).`);
|
||||
delete this.games[gameId];
|
||||
const gameIndexPending = this.pendingPvPGames.indexOf(gameId);
|
||||
if (gameIndexPending > -1) this.pendingPvPGames.splice(gameIndexPending, 1);
|
||||
for (const key in this.userToPendingGame) {
|
||||
if (this.userToPendingGame[key] === gameId) delete this.userToPendingGame[key];
|
||||
}
|
||||
this.broadcastAvailablePvPGames();
|
||||
// Если в игре больше нет игроков, удаляем ее из GameManager
|
||||
console.log(`[GameManager] Игра ${gameId} пуста после дисконнекта ${socketId} (идентификатор ${identifier}). Удаляем.`);
|
||||
// Используем централизованную функцию очистки
|
||||
this._cleanupGame(gameId, 'player_count_zero_on_disconnect');
|
||||
|
||||
} else if (game.mode === 'pvp' && game.playerCount === 1 && (!game.gameState || !game.gameState.isGameOver)) {
|
||||
// Если игра PvP, остался 1 игрок, и она еще не окончена (из-за дисконнекта),
|
||||
// возвращаем ее в список ожидающих.
|
||||
console.log(`[GameManager] Игра ${gameId} (PvP) теперь с 1 игроком после дисконнекта ${socketId} (идентификатор ${identifier}). Возвращаем в список ожидания.`);
|
||||
if (!this.pendingPvPGames.includes(gameId)) {
|
||||
this.pendingPvPGames.push(gameId);
|
||||
}
|
||||
const remainingPlayerSocketId = Object.keys(game.players)[0];
|
||||
const remainingPlayerSocket = game.players[remainingPlayerSocketId]?.socket;
|
||||
const remainingUserId = remainingPlayerSocket?.userData?.userId;
|
||||
const newIdentifier = remainingUserId || remainingPlayerSocketId;
|
||||
// Удаляем ссылку на игру только для отключившегося идентификатора
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
|
||||
game.ownerUserId = remainingUserId;
|
||||
this.userToPendingGame[newIdentifier] = gameId;
|
||||
// ownerIdentifier игры (если был userId) останется тем же, даже если отключился владелец.
|
||||
// Это OK, ownerIdentifier используется для _removePreviousPendingGames.
|
||||
|
||||
if (identifier !== newIdentifier && this.userToPendingGame[identifier] === gameId) {
|
||||
delete this.userToPendingGame[identifier];
|
||||
}
|
||||
console.log(`[GameManager] Игра ${gameId} возвращена в список ожидания PvP. Новый владелец: ${newIdentifier}`);
|
||||
this.broadcastAvailablePvPGames();
|
||||
this.broadcastAvailablePvPGames(); // Обновляем список у всех
|
||||
} else if (game.gameState?.isGameOver) {
|
||||
// Если игра была окончена (например, дисконнект приводил к gameOver),
|
||||
// просто удаляем ссылку на игру для отключившегося идентификатора.
|
||||
console.log(`[GameManager] Игрок ${identifier} отключился из завершенной игры ${gameId}. Удаляем ссылку.`);
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
} else {
|
||||
// Игра не пуста и не вернулась в ожидание (например, AI игра, где остался игрок,
|
||||
// или PvP игра с 2 игроками, где один отключился, а второй остался)
|
||||
// Ссылка userIdentifierToGameId[identifier] для отключившегося игрока должна быть удалена.
|
||||
console.log(`[GameManager] Игрок ${identifier} отключился из активной игры ${gameId} (mode: ${game.mode}, players: ${game.playerCount}). Удаляем ссылку.`);
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
}
|
||||
} else {
|
||||
const pendingGameIdToRemove = this.userToPendingGame[identifier];
|
||||
if (pendingGameIdToRemove && this.games[pendingGameIdToRemove] && this.games[pendingGameIdToRemove].playerCount === 1) {
|
||||
console.log(`[GameManager] Игрок ${socketId} (identifier: ${identifier}) отключился, удаляем его ожидающую игру ${pendingGameIdToRemove}`);
|
||||
delete this.games[pendingGameIdToRemove];
|
||||
const idx = this.pendingPvPGames.indexOf(pendingGameIdToRemove);
|
||||
if (idx > -1) this.pendingPvPGames.splice(idx, 1);
|
||||
delete this.userToPendingGame[identifier];
|
||||
this.broadcastAvailablePvPGames();
|
||||
// Игра найдена, но игрока с этим идентификатором или сокетом в game.players нет.
|
||||
// Это может означать, что сокет отключился, но запись игрока была удалена раньше,
|
||||
// или identifier некорректен.
|
||||
console.warn(`[GameManager] Игрок с идентификатором ${identifier} (сокет: ${socketId}) не найден в game.players для игры ${gameId}.`);
|
||||
// Удаляем ссылку на игру для этого идентификатора, если она есть.
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
// Проверяем, возможно, этот сокет был в другой игре по старой ссылке socketToGame (удалено),
|
||||
// или это просто отключившийся сокет без активной игры.
|
||||
}
|
||||
} else {
|
||||
// Если игра не найдена по userIdentifierToGameId[identifier]
|
||||
console.log(`[GameManager] Отключился сокет ${socketId} (идентификатор ${identifier}). Игровая сессия по этому идентификатору не найдена.`);
|
||||
// Убеждаемся, что ссылка userIdentifierToGameId[identifier] удалена
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
}
|
||||
delete this.socketToGame[socketId];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Централизованная функция для очистки игры после ее завершения.
|
||||
* Удаляет экземпляр игры и все связанные с ней ссылки.
|
||||
* Вызывается из GameInstance при gameOver (по HP или дисконнекту).
|
||||
* @param {string} gameId - ID завершенной игры.
|
||||
* @param {string} reason - Причина завершения (для логирования).
|
||||
* @returns {boolean} true, если игра найдена и очищена, иначе false.
|
||||
*/
|
||||
_cleanupGame(gameId, reason = 'unknown_reason') { // <-- НОВЫЙ ПРИВАТНЫЙ МЕТОД
|
||||
const game = this.games[gameId];
|
||||
if (!game) {
|
||||
console.warn(`[GameManager] _cleanupGame called for unknown game ID: ${gameId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[GameManager] Cleaning up game ${gameId} (Mode: ${game.mode}, Reason: ${reason})...`);
|
||||
|
||||
// Удаляем ссылку userIdentifierToGameId для всех игроков, которые были в этой игре
|
||||
// Перебираем players в GameInstance, чтобы получить идентификаторы
|
||||
Object.values(game.players).forEach(playerInfo => {
|
||||
if (playerInfo && playerInfo.identifier && this.userIdentifierToGameId[playerInfo.identifier] === gameId) {
|
||||
delete this.userIdentifierToGameId[playerInfo.identifier];
|
||||
console.log(`[GameManager] Removed userIdentifierToGameId for ${playerInfo.identifier}.`);
|
||||
} else if (playerInfo && playerInfo.identifier) {
|
||||
console.warn(`[GameManager] User ${playerInfo.identifier} in game ${gameId} has incorrect userIdentifierToGameId reference.`);
|
||||
// Если ссылка некорректна, ничего не удаляем.
|
||||
}
|
||||
});
|
||||
|
||||
// Удаляем ID игры из списка ожидающих, если она там была
|
||||
const pendingIndex = this.pendingPvPGames.indexOf(gameId);
|
||||
if (pendingIndex > -1) {
|
||||
this.pendingPvPGames.splice(pendingIndex, 1);
|
||||
console.log(`[GameManager] Removed game ${gameId} from pendingPvPGames.`);
|
||||
}
|
||||
|
||||
// Удаляем сам экземпляр игры
|
||||
delete this.games[gameId];
|
||||
console.log(`[GameManager] Deleted GameInstance for game ${gameId}.`);
|
||||
|
||||
// Оповещаем клиентов об обновленном списке игр (может понадобиться, если удалена ожидающая игра)
|
||||
// Или если активная игра была удалена, и игроки вернутся в лобби.
|
||||
this.broadcastAvailablePvPGames();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Формирует список доступных для присоединения PvP игр для клиента.
|
||||
* @returns {Array<object>} Массив объектов с информацией об играх.
|
||||
*/
|
||||
getAvailablePvPGamesListForClient() {
|
||||
return this.pendingPvPGames
|
||||
.map(gameId => {
|
||||
const game = this.games[gameId];
|
||||
if (game && game.mode === 'pvp' && game.playerCount === 1 && (!game.gameState || !game.gameState.isGameOver)) {
|
||||
// Проверяем, что игра существует, это PvP, в ней 1 игрок, и она не окончена
|
||||
// gameState.isGameOver проверяется, чтобы исключить игры, которые могли завершиться сразу (очень маловероятно)
|
||||
if (game && game.mode === 'pvp' && game.playerCount === 1 && game.gameState && !game.gameState.isGameOver) {
|
||||
let firstPlayerUsername = 'Игрок';
|
||||
let firstPlayerCharacterName = '';
|
||||
|
||||
if (game.players && Object.keys(game.players).length > 0) {
|
||||
const firstPlayerSocketId = Object.keys(game.players)[0];
|
||||
const firstPlayerInfo = game.players[firstPlayerSocketId];
|
||||
// Находим информацию о первом игроке (он всегда в слоте GAME_CONFIG.PLAYER_ID в ожидающей игре)
|
||||
const firstPlayerInfo = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
|
||||
|
||||
if (firstPlayerInfo) {
|
||||
// Получаем имя пользователя из userData, если залогинен
|
||||
if (firstPlayerInfo.socket?.userData?.username) {
|
||||
firstPlayerUsername = firstPlayerInfo.socket.userData.username;
|
||||
} else {
|
||||
// Если нет userData.username, используем часть identifier
|
||||
firstPlayerUsername = `User#${String(firstPlayerInfo.identifier).substring(0,6)}`; // Приводим identifier к строке
|
||||
}
|
||||
|
||||
// Получаем имя персонажа из chosenCharacterKey
|
||||
const charKey = firstPlayerInfo.chosenCharacterKey;
|
||||
if (charKey) {
|
||||
let charBaseStats;
|
||||
if (charKey === 'elena') {
|
||||
charBaseStats = gameData.playerBaseStats;
|
||||
} else if (charKey === 'almagest') {
|
||||
charBaseStats = gameData.almagestBaseStats;
|
||||
}
|
||||
// Баларда не должно быть в pending PvP как создателя
|
||||
|
||||
// Используем _getCharacterBaseData напрямую, т.к. gameData доступен
|
||||
const charBaseStats = this._getCharacterBaseData(charKey);
|
||||
if (charBaseStats && charBaseStats.name) {
|
||||
firstPlayerCharacterName = charBaseStats.name;
|
||||
} else {
|
||||
console.warn(`[GameManager] getAvailablePvPGamesList: Не удалось найти имя для charKey '${charKey}' в gameData.`);
|
||||
firstPlayerCharacterName = charKey; // В крайнем случае ключ
|
||||
//console.warn(`[GameManager] getAvailablePvPGamesList: Не удалось найти имя для charKey '${charKey}' в gameData.`);
|
||||
firstPlayerCharacterName = charKey; // В крайнем случае используем ключ
|
||||
}
|
||||
} else {
|
||||
console.warn(`[GameManager] getAvailablePvPGamesList: firstPlayerInfo.chosenCharacterKey отсутствует для игры ${gameId}`);
|
||||
}
|
||||
//console.warn(`[GameManager] getAvailablePvPGamesList: firstPlayerInfo.chosenCharacterKey отсутствует для игры ${gameId}.`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[GameManager] getAvailablePvPGamesList: firstPlayerInfo (Player 1) не найдена для ожидающей игры ${gameId}.`);
|
||||
firstPlayerUsername = 'Неизвестный игрок'; // Если даже игрока не нашли в players
|
||||
}
|
||||
|
||||
// Формируем строку статуса для отображения в списке
|
||||
let statusString = `Ожидает 1 игрока (Создал: ${firstPlayerUsername}`;
|
||||
if (firstPlayerCharacterName) {
|
||||
statusString += ` за ${firstPlayerCharacterName}`;
|
||||
@ -272,40 +522,271 @@ class GameManager {
|
||||
statusString += `)`;
|
||||
|
||||
return {
|
||||
id: gameId,
|
||||
id: gameId, // Отправляем полный ID, но в списке UI показываем обрезанный
|
||||
status: statusString
|
||||
};
|
||||
}
|
||||
return null;
|
||||
// Если игра не соответствует критериям ожидающей (например, пуста, заполнена, окончена), не включаем ее
|
||||
if (game && !this.pendingPvPGames.includes(gameId)) {
|
||||
// Если игра есть, но не в pendingPvPGames, она не должна тут обрабатываться.
|
||||
} else if (game && game.playerCount === 1 && (game.gameState?.isGameOver || !game.gameState)) {
|
||||
// Игра с 1 игроком, но окончена или не инициализирована - не показывать
|
||||
} else if (game && game.playerCount === 2) {
|
||||
// Игра заполнена - не показывать
|
||||
} else if (game && game.playerCount === 0) {
|
||||
// Игра пуста - ее надо было удалить при дисконнекте последнего игрока.
|
||||
// Возможно, тут нужна очистка таких "потерянных" игр.
|
||||
console.warn(`[GameManager] getAvailablePvPGamesList: Найдена пустая игра ${gameId} в games. Удаляем.`);
|
||||
delete this.games[gameId]; // Удаляем потерянную игру
|
||||
// Очистка из pendingPvPGames не нужна, т.к. она удаляется при playerCount === 0
|
||||
}
|
||||
|
||||
return null; // Исключаем игры, не соответствующие критериям или удаленные
|
||||
})
|
||||
.filter(info => info !== null);
|
||||
.filter(info => info !== null); // Удаляем null из результатов map
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет обновленный список доступных PvP игр всем подключенным клиентам.
|
||||
*/
|
||||
broadcastAvailablePvPGames() {
|
||||
this.io.emit('availablePvPGamesList', this.getAvailablePvPGamesListForClient());
|
||||
const availableGames = this.getAvailablePvPGamesListForClient();
|
||||
this.io.emit('availablePvPGamesList', availableGames);
|
||||
console.log(`[GameManager] Обновлен список доступных PvP игр. Всего: ${availableGames.length}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает список активных игр для отладки на сервере.
|
||||
* @returns {Array<object>} Список объектов с краткой информацией об играх.
|
||||
*/
|
||||
getActiveGamesList() { // Для отладки на сервере
|
||||
return Object.values(this.games).map(game => {
|
||||
let playerSlotChar = game.gameState?.player?.name || (game.playerCharacterKey ? gameData[game.playerCharacterKey === 'elena' ? 'playerBaseStats' : (game.playerCharacterKey === 'almagest' ? 'almagestBaseStats' : null)]?.name : 'N/A');
|
||||
let opponentSlotChar = game.gameState?.opponent?.name || (game.opponentCharacterKey ? gameData[game.opponentCharacterKey === 'elena' ? 'playerBaseStats' : (game.opponentCharacterKey === 'almagest' ? 'almagestBaseStats' : (game.opponentCharacterKey === 'balard' ? 'opponentBaseStats' : null))]?.name : 'N/A');
|
||||
// Получаем имена персонажей из gameState, если игра инициализирована, иначе из chosenCharacterKey/default
|
||||
let playerSlotCharName = game.gameState?.player?.name || (game.playerCharacterKey ? this._getCharacterBaseData(game.playerCharacterKey)?.name : 'N/A (ожидание)');
|
||||
let opponentSlotCharName = game.gameState?.opponent?.name || (game.opponentCharacterKey ? this._getCharacterBaseData(game.opponentCharacterKey)?.name : 'N/A (ожидание)');
|
||||
|
||||
if (game.mode === 'pvp' && game.playerCount === 1 && !game.opponentCharacterKey && game.gameState && !game.gameState.isGameOver) {
|
||||
opponentSlotChar = 'Ожидание...';
|
||||
}
|
||||
// Проверяем наличие игроков в слотах, чтобы уточнить статус
|
||||
const playerInSlot1 = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
|
||||
const playerInSlot2 = Object.values(game.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID);
|
||||
|
||||
if (!playerInSlot1) playerSlotCharName = 'Пусто';
|
||||
if (!playerInSlot2 && game.mode === 'pvp') opponentSlotCharName = 'Ожидание...'; // В PvP слоты могут быть пустыми
|
||||
if (!playerInSlot2 && game.mode === 'ai' && game.aiOpponent) opponentSlotCharName = 'Балард (AI)'; // В AI слоте оппонента всегда AI
|
||||
|
||||
return {
|
||||
id: game.id.substring(0,8),
|
||||
id: game.id.substring(0,8), // Обрезанный ID для удобства
|
||||
mode: game.mode,
|
||||
playerCount: game.playerCount,
|
||||
isGameOver: game.gameState ? game.gameState.isGameOver : 'N/A',
|
||||
playerSlot: playerSlotChar,
|
||||
opponentSlot: opponentSlotChar,
|
||||
ownerUserId: game.ownerUserId || 'N/A',
|
||||
pending: this.pendingPvPGames.includes(game.id)
|
||||
isGameOver: game.gameState ? game.gameState.isGameOver : 'N/A (Не инициализирована)',
|
||||
playerSlot: playerSlotCharName,
|
||||
opponentSlot: opponentSlotCharName,
|
||||
ownerIdentifier: game.ownerIdentifier || 'N/A',
|
||||
pending: this.pendingPvPGames.includes(game.id),
|
||||
turn: game.gameState ? `Ход ${game.gameState.turnNumber}, ${game.gameState.isPlayerTurn ? (playerInSlot1?.identifier || 'Player Slot') : (playerInSlot2?.identifier || 'Opponent Slot')}` : 'N/A'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает запрос клиента на gameState (например, при переподключении).
|
||||
* Находит игру пользователя по его идентификатору и отправляет ему актуальное состояние.
|
||||
* Также обновляет ссылку на сокет в GameInstance.
|
||||
* @param {object} socket - Сокет клиента, запросившего состояние.
|
||||
* @param {string|number} identifier - ID пользователя (userId или socketId).
|
||||
*/
|
||||
handleRequestGameState(socket, identifier) { // Принимаем socket и identifier
|
||||
// Ищем игру пользователя по его идентификатору
|
||||
const gameId = this.userIdentifierToGameId[identifier];
|
||||
let game = null;
|
||||
|
||||
if (gameId) {
|
||||
game = this.games[gameId];
|
||||
}
|
||||
|
||||
// Если игра найдена и она существует, и в ней есть игрок с этим идентификатором
|
||||
if (game && game.players) {
|
||||
const playerInfo = Object.values(game.players).find(p => p.identifier === identifier);
|
||||
|
||||
if (playerInfo) {
|
||||
// Проверяем, если игра окончена, не восстанавливаем состояние, а информируем
|
||||
if (game.gameState?.isGameOver) {
|
||||
console.log(`[GameManager] Reconnected user ${identifier} to game ${gameId} which is already over. Sending gameNotFound.`);
|
||||
// Удаляем ссылку на оконченную игру для этого пользователя
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
// Отправляем gameNotFound, чтобы клиент вернулся в меню
|
||||
socket.emit('gameNotFound', { message: 'Ваша предыдущая игровая сессия уже завершена.' });
|
||||
return; // Прекращаем обработку
|
||||
}
|
||||
|
||||
|
||||
console.log(`[GameManager] Found game ${gameId} for identifier ${identifier} (role ${playerInfo.id}). Reconnecting socket ${socket.id}.`);
|
||||
|
||||
// --- Обновляем GameInstance: заменяем старый сокет на новый для этого игрока ---
|
||||
// Удаляем старую запись игрока по старому socket.id, если она есть и отличается
|
||||
const oldSocketId = playerInfo.socket?.id;
|
||||
if (oldSocketId && oldSocketId !== socket.id && game.players[oldSocketId]) {
|
||||
console.log(`[GameManager] Updating socket ID for player ${identifier} from ${oldSocketId} to ${socket.id} in game ${gameId}.`);
|
||||
delete game.players[oldSocketId]; // Удаляем запись по старому socketId
|
||||
// playerCount не уменьшаем/увеличиваем, т.к. это тот же игрок, просто сменил сокет
|
||||
// Удаляем ссылку на старый сокет по роли
|
||||
if (game.playerSockets[playerInfo.id]?.id === oldSocketId) {
|
||||
delete game.playerSockets[playerInfo.id];
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем или обновляем запись для нового сокета, связывая его с существующим идентификатором игрока
|
||||
game.players[socket.id] = playerInfo; // Переиспользуем существующий объект playerInfo
|
||||
game.players[socket.id].socket = socket; // Обновляем объект сокета
|
||||
// Ensure the identifier and role are correct on the new socket entry
|
||||
game.players[socket.id].identifier = identifier; // Make sure identifier is set (уже должно быть, но на всякий случай)
|
||||
// playerInfo.id should already be correct (player/opponent role)
|
||||
|
||||
game.playerSockets[playerInfo.id] = socket; // Обновляем ссылку на сокет по роли
|
||||
|
||||
// Убеждаемся, что новый socket.id теперь связан с этой игрой в GameManager - НЕ НУЖНО, socketToGame удален
|
||||
// this.socketToGame[socket.id] = game.id;
|
||||
|
||||
|
||||
// Присоединяем новый сокет к комнате Socket.IO
|
||||
socket.join(game.id);
|
||||
// --- КОНЕЦ Обновления сокета ---
|
||||
|
||||
|
||||
// Получаем данные персонажей с точки зрения этого клиента
|
||||
// playerInfo.chosenCharacterKey - это персонаж этого клиента
|
||||
const playerCharDataForClient = this._getCharacterData(playerInfo.chosenCharacterKey);
|
||||
// Определяем ключ персонажа оппонента с точки зрения этого клиента
|
||||
const opponentActualSlotId = playerInfo.id === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
|
||||
const opponentCharacterKeyForClient = game.gameState?.[opponentActualSlotId]?.characterKey || null; // Берем из gameState, т.к. там актуальное состояние слотов
|
||||
// Если оппонент еще не определен в gameState (PvP ожидание), используем playerCharacterKey/opponentCharacterKey из gameInstance
|
||||
// ВАЖНО: при переподключении к *активной* игре, gameState.opponent.characterKey ДОЛЖЕН БЫТЬ определен.
|
||||
// Если он null, это может быть PvP ожидание или некорректное состояние.
|
||||
if (!opponentCharacterKeyForClient) {
|
||||
// Попробуем найти ключ из GameInstance properties (они устанавливаются при инициализации)
|
||||
const opponentSlotKeyInInstance = playerInfo.id === GAME_CONFIG.PLAYER_ID ? game.playerCharacterKey : game.opponentCharacterKey; // ИСПРАВЛЕНО: Логика получения ключа оппонента
|
||||
opponentCharacterKeyForClient = opponentSlotKeyInInstance;
|
||||
// Если даже из GameInstance properties ключ null, это точно PvP ожидание или критическая ошибка
|
||||
}
|
||||
const opponentCharDataForClient = this._getCharacterData(opponentCharacterKeyForClient); // Данные оппонента с т.з. клиента
|
||||
|
||||
|
||||
if (playerCharDataForClient && opponentCharDataForClient && game.gameState) {
|
||||
// Проверяем, готово ли gameState к игре (определены оба бойца)
|
||||
const isGameReadyForPlay = (game.mode === 'ai' && game.playerCount === 1) || (game.mode === 'pvp' && game.playerCount === 2);
|
||||
const isOpponentDefinedInState = game.gameState.opponent?.characterKey && game.gameState.opponent?.name !== 'Ожидание игрока...';
|
||||
|
||||
|
||||
socket.emit('gameState', {
|
||||
gameId: game.id,
|
||||
yourPlayerId: playerInfo.id, // ID слота этого клиента в игре
|
||||
gameState: game.gameState,
|
||||
playerBaseStats: playerCharDataForClient.baseStats, // Статы "моего" персонажа для клиента
|
||||
opponentBaseStats: opponentCharDataForClient.baseStats, // Статы "моего" оппонента для клиента
|
||||
playerAbilities: playerCharDataForClient.abilities, // Абилки "моего" персонажа для клиента
|
||||
opponentAbilities: opponentCharDataForClient.abilities, // Абилки "моего" оппонента для клиента
|
||||
log: game.consumeLogBuffer(), // Отправляем текущий лог и очищаем буфер игры
|
||||
clientConfig: { ...GAME_CONFIG } // Отправляем копию конфига
|
||||
});
|
||||
console.log(`[GameManager] Sent gameState to socket ${socket.id} (identifier: ${identifier}) for game ${game.id}.`);
|
||||
|
||||
// Логика старта игры при переподключении (если она еще не началась)
|
||||
// Эта логика должна быть только для случая, когда переподключившийся игрок ЗАВЕРШАЕТ состав игры
|
||||
// (например, второй игрок в PvP переподключился к ожидающей игре).
|
||||
// Если игра уже началась, startGame не должен вызываться повторно.
|
||||
// Проверяем: игра не окончена, готова к игре (2 игрока или AI), и состояние оппонента НЕ БЫЛО определено до этого запроса (признак не полностью стартовавшей игры)
|
||||
if (!game.gameState.isGameOver && isGameReadyForPlay && !isOpponentDefinedInState) {
|
||||
console.log(`[GameManager] Game ${game.id} found ready but not fully started on reconnect (Opponent state missing). Initializing/Starting.`);
|
||||
// Инициализируем состояние игры. initializeGame вернет true, если оба бойца определены.
|
||||
const isInitialized = game.initializeGame(); // Переинициализируем state полностью с обоими персонажами
|
||||
if (isInitialized) { // Проверяем, успешно ли инициализировалось состояние
|
||||
game.startGame(); // Запускаем игру (это отправит gameStarted всем, включая этого клиента)
|
||||
} else {
|
||||
console.error(`[GameManager] Failed to initialize game ${game.id} on reconnect. Cannot start.`);
|
||||
// Дополнительная обработка ошибки, возможно, уведомить игроков
|
||||
this.io.to(game.id).emit('gameError', { message: 'Ошибка сервера при старте игры после переподключения. Не удалось инициализировать игру.' });
|
||||
// Если инициализация провалилась, игра в некорректном состоянии, нужно ее удалить
|
||||
this._cleanupGame(gameId, 'reconnect_initialization_failed');
|
||||
}
|
||||
}
|
||||
// Если игра уже активно идет (не окончена, не ожидание) и состояние оппонента БЫЛО определено,
|
||||
// то startGame не вызывается повторно. Клиент получит gameStateUpdate от обычного хода игры.
|
||||
// Если игра PvP ожидающая (1 игрок), startGame не вызывается, isGameReadyForPlay будет false.
|
||||
else if (!isGameReadyForPlay) {
|
||||
console.log(`[GameManager] Reconnected user ${identifier} to pending game ${gameId}. Sending gameState and waiting status.`);
|
||||
// Если это ожидающая игра, убедимся, что клиент получает статус ожидания
|
||||
socket.emit('waitingForOpponent');
|
||||
} else if (game.gameState.isGameOver) {
|
||||
console.log(`[GameManager] Reconnected to game ${gameId} which is already over. Sending gameNotFound.`);
|
||||
// Если игра окончена, client.js должен по gameState.isGameOver показать модалку.
|
||||
// Но чтобы гарантировать возврат в меню при последующих запросах, лучше отправить gameNotFound.
|
||||
// Удаляем ссылку на оконченную игру для этого пользователя
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
// Отправляем gameNotFound
|
||||
socket.emit('gameNotFound', { message: 'Ваша предыдущая игровая сессия уже завершена.' });
|
||||
} else {
|
||||
// Переподключение к активной игре, которая уже полностью стартовала.
|
||||
console.log(`[GameManager] Reconnected user ${identifier} to active game ${gameId}. gameState sent.`);
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
console.error(`[GameManager] Failed to send gameState to ${socket.id} (identifier ${identifier}) for game ${gameId}: missing character data or gameState.`);
|
||||
socket.emit('gameError', { message: 'Ошибка сервера при восстановлении состояния игры.' });
|
||||
// Если данные для отправки некорректны, игра в некорректном состоянии, нужно ее удалить
|
||||
this._cleanupGame(gameId, 'reconnect_send_failed');
|
||||
socket.emit('gameNotFound', { message: 'Ваша игровая сессия в некорректном состоянии и была завершена.' });
|
||||
}
|
||||
|
||||
} else {
|
||||
// Игра найдена по идентификатору пользователя, но игрока с этим идентификатором нет в players этой игры.
|
||||
// Это очень странная ситуация, возможно, state userIdentifierToGameId некорректен.
|
||||
console.warn(`[GameManager] Found game ${gameId} by identifier ${identifier}, but player with this identifier not found in game.players.`);
|
||||
// Удаляем некорректную ссылку и отправляем gameNotFound
|
||||
delete this.userIdentifierToGameId[identifier];
|
||||
socket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена. Возможно, идентификатор пользователя некорректен.' });
|
||||
}
|
||||
} else {
|
||||
// Игра не найдена по userIdentifierToGameId[identifier]
|
||||
console.log(`[GameManager] No active or pending game found for identifier ${identifier}.`);
|
||||
socket.emit('gameNotFound', { message: 'Игровая сессия не найдена.' }); // Уведомляем клиента, что игра не найдена
|
||||
}
|
||||
}
|
||||
|
||||
// --- Вспомогательные функции для получения данных персонажа из data.js ---
|
||||
// Скопировано из gameInstance.js, т.к. gameManager тоже использует gameData напрямую
|
||||
/**
|
||||
* Получает базовые статы и список способностей для персонажа по ключу.
|
||||
* Эти функции предназначены для использования ВНУТРИ GameManager или GameInstance.
|
||||
* @param {string} key - Ключ персонажа ('elena', 'balard', 'almagest').
|
||||
* @returns {{baseStats: object, abilities: array}|null} Объект с базовыми статами и способностями, или null.
|
||||
*/
|
||||
_getCharacterData(key) {
|
||||
if (!key) { console.warn("GameManager::_getCharacterData called with null/undefined key."); return null; }
|
||||
switch (key) {
|
||||
case 'elena': return { baseStats: gameData.playerBaseStats, abilities: gameData.playerAbilities };
|
||||
case 'balard': return { baseStats: gameData.opponentBaseStats, abilities: gameData.opponentAbilities }; // Балард использует opponentAbilities из data.js
|
||||
case 'almagest': return { baseStats: gameData.almagestBaseStats, abilities: gameData.almagestAbilities }; // Альмагест использует almagestAbilities из data.js
|
||||
default: console.error(`GameManager::_getCharacterData: Unknown character key "${key}"`); return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Получает только базовые статы для персонажа по ключу.
|
||||
* @param {string} key - Ключ персонажа.
|
||||
* @returns {object|null} Базовые статы или null.
|
||||
*/
|
||||
_getCharacterBaseData(key) {
|
||||
const charData = this._getCharacterData(key);
|
||||
return charData ? charData.baseStats : null;
|
||||
}
|
||||
/**
|
||||
* Получает только список способностей для персонажа по ключу.
|
||||
* @param {string} key - Ключ персонажа.
|
||||
* @returns {array|null} Список способностей или null.
|
||||
*/
|
||||
_getCharacterAbilities(key) {
|
||||
const charData = this._getCharacterData(key);
|
||||
return charData ? charData.abilities : null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GameManager;
|
Loading…
x
Reference in New Issue
Block a user