#include "game.h" #include "sim/reactions.h" // Перевод экранных осей ввода в мировые — единственное место, // где симуляционный слой касается изометрии (см. docs/02-architecture.md). #include "engine/view2d.h" #include "sim/enemy.h" #include "sim/spawn_catalog.h" namespace { // Снять с мира все пули разом. Пуля — сущность, поэтому просто очистить вектор // нельзя: остались бы живые идентификаторы без компонентов. void ClearBullets(ecs::World& world) { std::vector& bullets = world.All(); while (!bullets.empty()) world.Destroy(bullets.back().self); } } // namespace Game::Game() : targets(world.Store().data), bullets(world.Store().data), floorItems(world.Store().data) { } void Game::Init() { sprites.Generate(); Reset(); } void Game::Reset() { BuildFloor(nullptr); xp = 0.0f; floorsCleared = 0; lootTaken = 0; } void Game::NewRun(int startDepth) { // Новый забег — новый сид. Сид крутится тем же линейным конгруэнтным шагом, // что и кнопка SEED в редакторе: «следующий» обязано быть одним и тем же // понятием в обоих приложениях, иначе сид, подобранный в редакторе, в игре // не повторить. levelSeed = levelSeed * 1664525u + 1013904223u; // Начать глубже открытого чекпоинта нельзя: иначе достигнутая глубина // перестаёт что-либо значить, а вместе с ней и весь риск. depth = std::max(1, startDepth); if (profile) depth = std::min(depth, profile->DeepestCheckpoint()); report = RunReport{}; if (profile) ++profile->runs; Reset(); ApplyProfileLoadout(); } void Game::Extract() { // Унести можно только то, что на ВЫЖИВШИХ. Павший забирает своё с собой — // в этом вся ставка: не «доживи», а «доживи, имея что вынести». HarvestToProfile(true); FinishRun(RunOutcome::EXTRACTED); } void Game::WipeRun() { // Ничего не уносится. Снаряжение сбрасывается к стартовому, но схрон и // чекпоинты остаются: потерять вылазку — не то же самое, что потерять // всё нажитое, и второе прекращало бы игру, а не наказывало. if (profile) profile->ResetLoadouts(); FinishRun(RunOutcome::WIPED); } void Game::FinishRun(RunOutcome outcome) { report.outcome = outcome; report.depth = depth; report.deepest = std::max(report.deepest, depth); report.survivors = AliveAgents(); report.xp = xp; if (!profile) { report.checkpoint = 1; return; } if (outcome == RunOutcome::EXTRACTED) { ++profile->extractions; profile->bankedXp += xp; } else { ++profile->wipes; } // Рекорд глубины засчитывается ЛЮБЫМ исходом. Дошёл — значит дошёл; // требовать за чекпоинт ещё и удачную эвакуацию значило бы наказывать // дважды за одну смерть. report.newRecord = report.deepest > profile->maxDepth; profile->maxDepth = std::max(profile->maxDepth, report.deepest); report.checkpoint = profile->DeepestCheckpoint(); } void Game::HarvestToProfile(bool onlySurvivors) { if (!profile) return; report.itemsBrought = 0; report.itemsLost = 0; for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = squad.agents[i]; const Inventory* inv = world.Get(a.self); if (!inv) continue; auto countCarried = [&](const Inventory& v) { int n = 0; if (v.weapon != ItemId::NONE) ++n; if (v.armor != ItemId::NONE) ++n; if (v.trinket != ItemId::NONE) ++n; return n; }; if (onlySurvivors && !a.alive) { report.itemsLost += countCarried(*inv); // Слот павшего получает стартовый набор: следующая вылазка идёт // впятером, и пустые руки одного из них — это не сложность, // а неиграбельность. profile->loadout[i] = Inventory{}; continue; } profile->loadout[i] = *inv; report.itemsBrought += countCarried(*inv); } // Общая сумка уходит в схрон целиком, если из забега вообще кто-то вышел. // // Правило «унести можно только то, что на выживших» этим не ослаблено, оно // стало точнее: НАДЕТОЕ павшего теряется по-прежнему, а общая ноша на нём // не висела — её тащил отряд. Вайп по-прежнему не выносит ничего: тогда // выживших нет вовсе, и сумку некому донести. const bool anySurvivor = AliveAgents() > 0; for (int s = 0; s < SQUAD_BAG_SLOTS; ++s) { const ItemId id = squad.bag.slots[s]; if (id == ItemId::NONE) continue; if (anySurvivor && profile->StashPush(id)) ++report.itemsBrought; else ++report.itemsLost; } squad.bag.Clear(); // Пустой слот павшего добивается стартовым комплектом. Делается через // ResetLoadouts только для тех, у кого ничего нет: иначе выжившие // потеряли бы найденное. Profile fresh; fresh.ResetLoadouts(); for (int i = 0; i < NUM_AGENTS; ++i) if (profile->loadout[i].weapon == ItemId::NONE) profile->loadout[i] = fresh.loadout[i]; } void Game::ApplyProfileLoadout() { if (!profile) return; for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; Inventory* inv = world.Get(a.self); if (!inv) continue; *inv = profile->loadout[i]; // Ствол в руках живёт ещё и отдельной сущностью-оружием: без этого // боец выходит с карабином по умолчанию, что бы ни лежало в профиле. EquipWeapon(world, a, inv->weapon); // Запас здоровья зависит от надетого (броня, оберег), и посчитан он // будет только на первом шаге. Без явной синхронизации боец выходит // на этаж с прежними ста единицами при полутора сотнях максимума — // то есть уже раненым, ничего не сделав. SyncLoadout(world, a); a.hp = a.maxHp; } } void Game::Descend() { // Отряд переезжает КАК ЕСТЬ: здоровье, оружие и сумки. В этом весь забег — // спуск обязан ощущаться как продолжение, а не как новый заход. SquadCarry carry; SnapshotSquad(carry); ++depth; ++floorsCleared; report.deepest = std::max(report.deepest, depth); BuildFloor(&carry); } // ----------------------------------------------------------------------------- // Что переезжает между этажами. Отдельная структура, а не «не трогать мир»: // Reset пересоздаёт весь ecs::World, и по-другому сохранить бойцов нельзя без // того, чтобы половина систем начала различать «новый мир» и «тот же мир». // ----------------------------------------------------------------------------- void Game::SnapshotSquad(SquadCarry& out) const { for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = squad.agents[i]; out.alive[i] = a.alive; out.hp[i] = a.hp; out.ammo[i] = a.ammoReserve; if (const Inventory* inv = world.Get(a.self)) out.inv[i] = *inv; } out.valid = true; } void Game::RestoreSquad(const SquadCarry& carry) { if (!carry.valid) return; for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; if (Inventory* inv = world.Get(a.self)) { *inv = carry.inv[i]; // Ствол в руках — часть инвентаря, но живёт ещё и отдельной // сущностью-оружием. Без этого боец приезжает на новый этаж с // карабином по умолчанию, что бы он ни нашёл этажом выше. EquipWeapon(world, a, inv->weapon); } // Павший остаётся павшим: воскрешение на лестнице обесценило бы // единственную настоящую потерю в забеге. a.alive = carry.alive[i]; a.hp = a.alive ? std::max(1.0f, carry.hp[i]) : 0.0f; a.ammoReserve = carry.ammo[i]; } } void Game::BuildFloor(const SquadCarry* carry) { // Этаж из комнат — только если набор подключён снаружи. Не собрался // (пустой набор, битые комнаты) — откатываемся на захардкоженную карту: // прототип обязан запускаться на голом репозитории. levelGenerated = false; levelUnknownSpawns = 0; atExit = false; Vec2 start{SQUAD_START_X, SQUAD_START_Y}; const FloorSpec spec = DescribeFloor(depth); if (rooms && catalog && rooms->Count() > 0 && GenerateLevel(*rooms, *catalog, spec, levelSeed, map, level)) { levelGenerated = true; start = level.squadStart; } else { map.Generate(); level = GeneratedLevel{}; level.floor = spec; } world.Clear(); // бойцы, оружие, мишени, пули — всё разом squad.Init(world, start); if (carry) RestoreSquad(*carry); // Собранный этаж населяет СЕБЯ САМ: захардкоженная расстановка мишеней // и тварей относится к другой карте, и мешать их — верный способ получить // тварь в стене на пустом месте. if (levelGenerated) { int unknownLoot = 0; SpawnFromLevel(world, level.spawns, map, &levelUnknownSpawns); SpawnLootFromLevel(world, level.loot, &unknownLoot); levelUnknownSpawns += unknownLoot; } else { SpawnTargets(world, combatMode, &map); } // Снаряжение раздаётся только тем, кто пришёл ни с чем: переезжающий отряд // уже одет тем, что нашёл, и выдавать ему стартовый комплект заново // означало бы стирать весь смысл находок. // // Есть профиль — одеваемся из него: это и есть «в чём вернулись с прошлой // вылазки». Нет профиля (приёмка, dev-прогоны) — прежний фиксированный // комплект, чтобы замеры не поехали. if (!carry) { if (profile) ApplyProfileLoadout(); else if (combatMode) EquipKits(); // Улучшения профиля — сюда же: они часть того, с чем отряд выходит на // вылазку. Нет профиля (приёмка, dev-прогоны) — нет и прибавок. squad.carryBonus = 0.0f; squad.sightBonus = 0.0f; squad.hpBonus = 0.0f; float ammoBonus = 0.0f; if (profile) { squad.carryBonus = UPGRADE_CARRY_STEP * float(profile->UpgradeLevel(Upgrade::CARRY)); squad.sightBonus = UPGRADE_SIGHT_STEP * float(profile->UpgradeLevel(Upgrade::SIGHT)); squad.hpBonus = UPGRADE_VIGOR_STEP * float(profile->UpgradeLevel(Upgrade::VIGOR)); ammoBonus = UPGRADE_AMMO_STEP * float(profile->UpgradeLevel(Upgrade::AMMO)); } // Запас патронов выдаётся вместе со снаряжением и только в БОЮ. // В песочнице приёмки его нет вовсе (-1 = бесконечно): критерии меряют // правила стрельбы, а не то, на сколько минут хватило патронов. for (int i = 0; i < NUM_AGENTS; ++i) squad.agents[i].ammoReserve = combatMode ? int(g_tune.startAmmo + ammoBonus) : -1; } lanes.Clear(); metrics.Reset(); events.Clear(); aim = AimState{}; for (bool& v : lastSolveValid) v = false; simTime = 0.0f; } void Game::PlaceSquad(Vec2 at, bool clumped) { squad.anchor = at; squad.anchorPrev = at; squad.anchorVel = {}; squad.state = AgentState::HOLDING; squad.idleTimer = g_tune.holdDelay; squad.holdElapsed = 0.0f; squad.urgency = 0.0f; squad.sprinting = false; squad.formSpacing = 1.0f; squad.formStretch = 1.0f; for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; a.pos = clumped ? at : squad.SlotWorld(i); map.ResolveCircle(a.pos, a.bodyRadius); a.prevPos = a.pos; a.postPos = a.pos; a.vel = {}; a.commitTimer = 0.0f; a.state = AgentState::HOLDING; a.currentTargetId = -1; a.hasLane = false; // Недорубленный взмах пережил бы расстановку и уехал в следующий // сценарий приёмки. Параметры при этом не трогаем — они из tuning. a.melee.state = MeleeState::IDLE; a.melee.phase = 0.0f; a.melee.cooldown = 0.0f; a.melee.targetId = -1; a.melee.inReach = false; a.melee.arcBlockedByAlly = false; a.melee.hitMask = 0; a.melee.comboStep = 0; // связка тоже не должна переезжать в новый сценарий a.melee.comboTimer = 0.0f; // Магазин тоже относится к расстановке: недозаряженный ствол переехал бы // в следующий сценарий приёмки вместе с бойцом. a.gun.ammo = a.gun.def.magazine; a.gun.reloadTimer = 0.0f; a.gun.cooldown = 0.0f; a.gun.recoilDeg = 0.0f; a.gun.shotSeq = 0; // Расстановка — это НАЧАЛО сценария. Павший боец, переехавший в него // трупом, испортил бы и замеры, и игру. a.alive = true; a.hp = a.maxHp; a.hurtTimer = 0.0f; // Захват — тоже состояние сценария: боец, переехавший в новый сценарий // в лапах хвата, просто не сдвинулся бы с места. a.grabbedTimer = 0.0f; } ClearBullets(world); lanes.Clear(); metrics.Reset(); events.Clear(); simTime = 0.0f; } SolverContext Game::MakeContext() const { SolverContext ctx; ctx.map = ↦ ctx.agents = squad.agents.data(); ctx.agentCount = NUM_AGENTS; ctx.targets = &targets; ctx.lanes = &lanes; ctx.anchor = squad.anchor; ctx.assignedTargetId = aim.manual ? aim.assignedTargetId : -1; return ctx; } // ----------------------------------------------------------------------------- // Расходники. Одно место на все: бинт, стим и пачка патронов — это «предмет, // который тратится, когда стало нужно», и разводить их по разным системам // значило бы трижды написать одно правило. // // Лечиться можно только ВНЕ схватки: перевязка посреди боя обесценила бы урон, // который тварь только что нанесла. Патроны — наоборот, набиваются когда угодно: // боец с пустым стволом и пачкой в сумке, стоящий столбом, выглядел бы поломкой. // ----------------------------------------------------------------------------- void Game::UseConsumables(float dt) { (void)dt; for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; if (!a.alive) continue; const Inventory* inv = world.Get(a.self); if (!inv) continue; // Насколько близко ближайшая живая тварь: лечиться при ней нельзя. float nearest = 1e9f; for (const Target& t : targets) { if (!t.alive || t.IsProp()) continue; nearest = std::min(nearest, Distance(t.pos, a.pos)); } const bool hurt = a.hp < a.maxHp * g_tune.healAtFrac; const bool safe = nearest > g_tune.healSafeDist; // Патроны нужны тому, у кого ВООБЩЕ есть ствол, — не только тому, кто // держит его прямо сейчас. Боец с клинком в руках и запасным карабином // в сумке иначе таскал бы найденную пачку до конца забега мёртвым // грузом, а это ровно то, от чего расходники и лечились. bool ownsGun = !a.IsMelee(); for (int s = 0; s < SQUAD_BAG_SLOTS && !ownsGun; ++s) if (ItemDefOf(squad.bag.slots[s]).kind == ItemKind::FIREARM) ownsGun = true; const bool dry = (a.ammoReserve == 0) && ownsGun; for (int s = 0; s < SQUAD_BAG_SLOTS; ++s) { const ItemDef d = ItemDefOf(squad.bag.slots[s]); if (d.kind != ItemKind::CONSUMABLE) continue; const bool healsNow = (d.heal > 0.0f) && hurt && safe; const bool loadsNow = (d.rounds > 0) && dry; if (!healsNow && !loadsNow) continue; if (healsNow) a.hp = std::min(a.maxHp, a.hp + d.heal); if (loadsNow) a.ammoReserve += d.rounds; squad.bag.slots[s] = ItemId::NONE; events.Push(CombatEventKind::ITEM_USED, i, a.pos, Vec2{}); ++metrics.itemsUsed; break; // не больше одного расходника за шаг: тратить пачку разом незачем } } } void Game::Step(float dt) { if (in.setFormation >= 0 && in.setFormation < int(Formation::COUNT)) squad.formation = Formation(in.setFormation); // --- 0. статы снаряжения: с сущностей-оружия на бойцов --------------------- // Одна система в начале шага. Всё остальное дальше читает уже готовые числа // и не лазает в ECS: ai/ про мир сущностей не знает вообще. // Общая ноша делится поровну на живых. Потерять бойца — значит взвалить // его долю на остальных: обратно наверх отряд пойдёт медленнее, чем шёл // сюда, и это не побочный эффект, а то, ради чего сумка общая. const float bagShare = squad.bag.Load() / float(squad.Carriers()); SquadBuffs buffs; buffs.sight = squad.sightBonus; buffs.hp = squad.hpBonus; buffs.carry = squad.carryBonus; for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i], bagShare, buffs); for (int i = 0; i < NUM_AGENTS; ++i) squad.agents[i].hurtTimer = std::max(0.0f, squad.agents[i].hurtTimer - dt); // --- 0.5 расходники --------------------------------------------------------- // Ручных действий в игре нет (спек 5), поэтому расходники боец применяет // САМ. Иначе бинт остаётся числом в каталоге и мёртвым грузом в сумке — // ровно тем, чем он и был до этой вехи. UseConsumables(dt); UpdateTargets(targets, dt); // Твари ходят и бьют ДО того, как отряд принимает решения: боец реагирует // на то, что видит сейчас, а не на прошлый кадр. UpdateEnemies(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, &events); // --- 1. якорь и состояние отряда ----------------------------------------- // Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде. const Vec2 moveWorld = v2d::ScreenDirToWorld(in.moveScreen); squad.UpdateAnchorAndState(moveWorld, in.sprint, map, dt); // --- 2. движение (реестр линий — с предыдущего шага, задержка 1/60 с) ----- squad.UpdateMovement(map, enableLaneAvoid ? &lanes : nullptr, targets, dt); // --- 3. ручное прицеливание (6.8) ---------------------------------------- UpdateAim(dt); // --- 4. цели по НОВЫМ позициям: линии огня у стрелков, сектор у клинков --- SolverContext ctx = MakeContext(); for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; if (!a.alive) continue; if (a.IsMelee()) EvaluateMeleeTarget(a, i, ctx); else EvaluateAgentLane(a, i, ctx); } // --- 5. реестр активных линий (6.6) -------------------------------------- lanes.Clear(); for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = squad.agents[i]; if (!a.alive) continue; // мёртвый ничего не резервирует if (a.IsMelee()) { // Траектория клинка резервируется тем же механизмом, что и линия // огня: свои расступаются с дороги замаха, а не выясняют это телом. // Резерв задаётся явно и шире отрезка — по боковому размаху сектора, // иначе союзника отжимает ровно во фланг дуги. if (a.melee.targetId >= 0 && (a.melee.inReach || a.melee.Committed())) { const float flank = a.melee.reach * std::sin(a.melee.HalfArc()); lanes.Add(i, a.pos, MeleeStrikeEnd(a, ctx), flank * g_tune.meleeReserveFrac); } continue; } if (a.hasLane && a.currentTargetId >= 0) { lanes.Add(i, a.MuzzleTowards(targets[a.currentTargetId].pos), targets[a.currentTargetId].pos); } else if (a.laneIntent) { // Линия перекрыта своим: резервируем её, чтобы перекрывшего отжало // в сторону. Без этого сектор обстрела никогда не освободится. lanes.Add(i, a.MuzzleTowards(a.laneEnd), a.laneEnd); } } // --- 6. решатель огневых позиций (6.5) / подход клинка -------------------- for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; if (!a.alive) { lastSolveValid[i] = false; continue; } a.commitTimer = std::max(0.0f, a.commitTimer - dt); if (a.IsMelee()) { // Решателю здесь делать нечего: у клинка одна огневая позиция — // вплотную к цели. Нет цели в своём круге — назад в строй. a.postPos = MeleeGoal(a, ctx, squad.SlotWorld(i)); lastSolveValid[i] = false; continue; } a.solveTimer -= dt; if (a.solveTimer > 0.0f) continue; a.solveTimer += g_tune.solveInterval; if (!enableSolver) continue; if (a.state != AgentState::HOLDING) continue; if (a.commitTimer > 0.0f) continue; if (a.priorityLane) continue; // линия к приоритетной цели уже есть lastSolveValid[i] = SolveFiringPosition(a, i, ctx, lastSolve[i]); // Решателю не по чему работать (ни одной цели в досягаемости) — случай, // который спек не описывает. Возвращаемся в свой слот строя, иначе агент, // отставший от отряда, остаётся стоять на месте навсегда. if (!lastSolveValid[i]) a.postPos = squad.SlotWorld(i); } // --- 7. доворот ствола / клинка ------------------------------------------- for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; if (!a.alive) continue; // На замахе и ударе ось зафиксирована: доворачивать её задним числом — // это стрельба с самонаведением, только клинком. if (a.IsMelee() && a.melee.Committed()) { a.facing = a.melee.dir; continue; } if (a.currentTargetId >= 0) AimAt(a, targets[a.currentTargetId].pos, dt); else AimAlongMotion(a, dt); } // --- 8. атака: огонь и клинок --------------------------------------------- FireWeapons(ctx, dt); UpdateMelee(ctx, dt); // --- 9. пули --------------------------------------------------------------- UpdateBullets(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, metrics, &events, depth); // --- 10. находки ----------------------------------------------------------- // После пуль и клинка: предмет, выпавший из разбитого ящика на этом же шаге, // обязан быть подбираемым сразу, а не через кадр. lootTaken += UpdateLootPickup(world, squad.agents.data(), NUM_AGENTS, squad.bag, squad.Carriers(), squad.carryBonus, &events, dt); // --- 11. опыт за убитых ---------------------------------------------------- AwardKillXp(); // --- 12. спуск ------------------------------------------------------------- // Симуляция только ОТМЕЧАЕТ, что отряд дошёл. Сам этаж она не меняет: смена // мира посреди шага, между движением и стрельбой, оставила бы половину // систем со ссылками на снесённые сущности. UpdateExit(); // --- 13. метрики ----------------------------------------------------------- metrics.Sample(squad, dt); simTime += dt; } // ----------------------------------------------------------------------------- // Опыт. Цена убитого — его danger из каталога движка, помноженная на множитель // этажа: и то и другое живёт в данных, а не здесь. // // Обстановка опыта не даёт по определению: ящик — это обстановка, и платить за // него значило бы сделать разбивание хлама самым выгодным занятием в игре. // ----------------------------------------------------------------------------- void Game::AwardKillXp() { const float scale = g_tune.xpPerDanger * level.floor.xpScale; for (Target& t : targets) { if (t.alive) { t.countedDead = false; continue; } if (t.countedDead) continue; t.countedDead = true; if (t.IsProp() || t.IsDummy()) continue; float danger = 1.0f; if (catalog) if (const EntityDesc* d = catalog->Find(IdOfEnemyKind(t.kind))) danger = d->danger; xp += danger * scale; } } void Game::UpdateExit() { if (!levelGenerated) { atExit = false; atExtract = false; return; } const float r = g_tune.descendRadius; const bool nearExit = Distance(squad.anchor, level.exitPos) <= r; const bool nearOut = Distance(squad.anchor, level.extractPos) <= r; // События пишутся на ФРОНТЕ: иначе журнал забивался бы одним и тем же // сообщением каждый кадр, пока отряд стоит на месте. if (nearExit && !atExit) events.Push(CombatEventKind::DESCEND, -1, level.exitPos, Vec2{}); if (nearOut && !atExtract) events.Push(CombatEventKind::EXTRACT, -1, level.extractPos, Vec2{}); atExit = nearExit; atExtract = nearOut; } void Game::UpdateAim(float dt) { if (in.toggleAimMode) { aim.manual = !aim.manual; if (aim.manual) { // Стартуем от текущего направления отряда. aim.dir = AngleOf(squad.forward); aim.assignedTargetId = -1; } else { aim.assignedTargetId = -1; } } if (!aim.manual) { aim.assignedTargetId = -1; return; } aim.dir = WrapPi(aim.dir + in.aimTurn * g_tune.aimTurnRateDeg * DEG2RAD_F * dt); // Назначенная цель — ближайшая мишень в конусе +-aimConeDeg от вектора прицела. const float cone = g_tune.aimConeDeg * DEG2RAD_F; // Собираем кандидатов в конусе, отсортированных по дальности от якоря. int inCone[MAX_TARGETS]; int coneCount = 0; for (int i = 0; i < int(targets.size()) && coneCount < MAX_TARGETS; ++i) { if (!targets[i].alive) continue; const Vec2 d = targets[i].pos - squad.anchor; if (std::fabs(AngleDiff(aim.dir, AngleOf(d))) > cone) continue; inCone[coneCount++] = i; } for (int i = 1; i < coneCount; ++i) // вставками, список крошечный { const int v = inCone[i]; int j = i - 1; while (j >= 0 && DistanceSq(targets[inCone[j]].pos, squad.anchor) > DistanceSq(targets[v].pos, squad.anchor)) { inCone[j + 1] = inCone[j]; --j; } inCone[j + 1] = v; } if (coneCount == 0) { aim.assignedTargetId = -1; return; } int current = -1; for (int i = 0; i < coneCount; ++i) if (inCone[i] == aim.assignedTargetId) current = i; if (current < 0) aim.assignedTargetId = inCone[0]; else if (in.cycleTarget) aim.assignedTargetId = inCone[(current + 1) % coneCount]; } // ----------------------------------------------------------------------------- // Огонь: перезарядка, увод ствола, спуск. // // Про разброс. Раньше пуля летела РОВНО по проверенному отрезку, и это было // главным аргументом за «FRIENDLY_HITS = 0 без исключений». Теперь у выстрела // есть конус, и аргумент остался тем же — просто проверяется не отрезок, а весь // конус (ai/firing_solver.cpp, ConeClearOfAllies). Пуля физически не может уйти // туда, где не проверяли: угол отклонения берётся из ТОЙ ЖЕ величины SpreadRad, // по которой считалась ширина проверки. // // Разброс не случаен в смысле генератора: это чистый хеш от (стрелок, номер // выстрела). Прогон воспроизводим, --headless по-прежнему годится как тест. // ----------------------------------------------------------------------------- void Game::FireWeapons(const SolverContext& ctx, float dt) { for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; a.fired = false; if (!a.alive || a.IsMelee()) continue; Firearm& g = a.gun; g.cooldown = std::max(0.0f, g.cooldown - dt); // Увод ствола оседает сам: очередь «раскрывается», пауза её собирает. g.recoilDeg = std::max(0.0f, g.recoilDeg - g.def.recoilDecay * dt); // --- перезарядка ------------------------------------------------------ if (g.Reloading()) { g.reloadTimer -= dt; if (g.reloadTimer <= 0.0f) { g.reloadTimer = 0.0f; // Магазин набивается ИЗ ЗАПАСА. Запас отрицательный — песочница // приёмки, там патроны бесконечны (sim/agent.h). if (a.ammoReserve < 0) { g.ammo = g.def.magazine; } else { const int take = std::min(g.def.magazine, a.ammoReserve); g.ammo = take; a.ammoReserve -= take; } events.Push(CombatEventKind::RELOAD_DONE, i, a.pos, FromAngle(a.facing)); } continue; } if (g.Empty()) { // Нечем набивать — перезарядка даже не начинается. Боец с пустым // стволом не изображает работу: он молчит, и это видно. if (a.ammoReserve == 0) continue; g.reloadTimer = g.def.reloadTime; events.Push(CombatEventKind::RELOAD_START, i, a.pos, FromAngle(a.facing)); ++metrics.reloads; continue; } // Бег с курсором на вытянутом поводке — это приказ идти, а не драться. if (squad.sprinting) continue; if (!a.hasLane || a.currentTargetId < 0) continue; if (g.cooldown > 0.0f) continue; const Vec2 tp = targets[a.currentTargetId].pos; // Ствол должен быть доведён до цели — иначе выстрел выглядит враньём. if (std::fabs(AngleDiff(a.facing, AngleOf(tp - a.pos))) > g_tune.aimToleranceDeg * DEG2RAD_F) continue; // Повторная проверка конуса по позициям этого шага: «никаких исключений». if (!LaneClearFrom(a.pos, i, tp, ctx)) continue; const Vec2 muzzle = a.MuzzleTowards(tp); // И проверка на время полёта пули — свой не должен успеть влезть. if (AllyInterceptsShot(muzzle, tp, i, ctx)) continue; // Отклонение внутри уже проверенного конуса. // // Картечь ничего не ослабляет: ВСЕ дробины уходят в тот же конус // SpreadRad, который только что проверили на своих. Поэтому у дробовика // конус широкий, и выстрел отсекается чаще, — это его цена, а не // послабление правила 6.3. const float spread = a.SpreadRad(); const float axis = AngleOf(Normalized(tp - muzzle, FromAngle(a.facing))); const float travel = Distance(muzzle, tp); const int pellets = std::max(1, g.def.pellets); Vec2 dir{}; for (int p = 0; p < pellets; ++p) { dir = FromAngle(axis + g.ShotOffset(i, spread, p)); SpawnBullet(world, g.def, muzzle, dir, travel, i); } events.Push(CombatEventKind::SHOT, i, muzzle, dir); g.cooldown = g.def.fireInterval; g.ammo = std::max(0, g.ammo - 1); ++g.shotSeq; g.recoilDeg = std::min(g.def.spreadRecoilMax, g.recoilDeg + g.def.spreadRecoilDeg); a.fired = true; ++metrics.shotsFired; } } // ----------------------------------------------------------------------------- // Ближний бой: замах -> удар -> восстановление. // // Правило то же, что и у стрельбы, только в угловой форме: свой в секторе — // не бьём. Оно проверяется дважды, как и у ствола: // 1) на старте замаха — сектор reach x arcDeg обязан быть чист; // 2) каждый шаг активного окна — если свой всё-таки влез под лезвие, // взмах ОБРЫВАЕТСЯ. Урон союзнику не наносится ни при каком раскладе, // поэтому FRIENDLY_HITS клинок физически поднять не может. // // Урон идёт не «по факту начала удара», а по реально пройденной за шаг полосе // лезвия: то, что видно на экране, и то, что получило урон, — одна и та же дуга. // ----------------------------------------------------------------------------- void Game::UpdateMelee(const SolverContext& ctx, float dt) { const float bladeHalf = g_tune.meleeBladeDeg * 0.5f * DEG2RAD_F; for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; if (!a.alive || !a.IsMelee()) continue; MeleeWeapon& m = a.melee; m.cooldown = std::max(0.0f, m.cooldown - dt); a.fired = false; switch (m.state) { case MeleeState::IDLE: { // Связка живёт своим окном: не продолжил вовремя — начинаешь // серию сначала. m.comboTimer = std::max(0.0f, m.comboTimer - dt); if (m.comboTimer <= 0.0f) m.comboStep = 0; // На бегу боец не гоняется за целями (за него это решает // Squad::GoalFor — он идёт в слот), но если цель сама оказалась // под рукой, клинок работает. Бой на ходу — это нормально. if (m.targetId < 0 || !m.inReach) break; if (m.cooldown > 0.0f) break; if (m.arcBlockedByAlly) break; // посчитано в EvaluateMeleeTarget const Vec2 tp = targets[m.targetId].pos; const float axis = AngleOf(tp - a.pos); // Корпус должен быть довёрнут — иначе удар выглядит враньём // ровно так же, как выстрел мимо ствола. if (std::fabs(AngleDiff(a.facing, axis)) > g_tune.meleeAimToleranceDeg * DEG2RAD_F) break; const MeleeComboStep& cs = m.NextComboStep(); m.BakeSwing(cs); // ширина, урон и тайминги — на весь взмах m.state = MeleeState::WINDUP; m.phase = m.curWindup; m.dir = axis; if (cs.flipSide) m.sweepSign = -m.sweepSign; m.hitMask = 0; // Интервал считается от НАЧАЛА замаха; внутри связки он короче. m.cooldown = m.interval * cs.intervalMul; break; } case MeleeState::WINDUP: { m.phase -= dt; if (m.phase > 0.0f) break; m.state = MeleeState::STRIKE; m.phase = m.curStrike; events.Push(CombatEventKind::MELEE_SWING, i, a.pos, FromAngle(m.dir)); ++metrics.meleeSwings; if (m.comboStep >= 0 && m.comboStep < MELEE_COMBO_STEPS) ++metrics.meleeComboSwings[m.comboStep]; break; } case MeleeState::STRIKE: { const float p0 = m.Progress(); m.phase -= dt; const float p1 = m.Progress(); const float ang0 = m.BladeAngleAt(p0); const float ang1 = m.BladeAngleAt(p1); // 1. свой под лезвием -> взмах оборван bool aborted = false; for (int k = 0; k < ctx.agentCount; ++k) { if (k == i) continue; const Agent& ally = ctx.agents[k]; if (!BladeSweepHits(a.pos, ang0, ang1, bladeHalf, m.reach + g_tune.meleeAllyMargin, ally.pos, ally.bodyRadius + g_tune.meleeAllyMargin)) continue; aborted = true; break; } if (aborted) { events.Push(CombatEventKind::MELEE_ABORT, i, a.pos, FromAngle(ang1)); ++metrics.meleeAborts; m.state = MeleeState::RECOVER; m.phase = m.curRecover; break; } // 2. мишени, по одной за взмах // MAX_TARGETS — это ширина hitMask, а не «столько влезет»: // за пределами маски удар прошёл бы по одной цели дважды. for (int t = 0; t < int(targets.size()) && t < MAX_TARGETS; ++t) { Target& tg = targets[t]; if (!tg.alive) continue; if (m.hitMask & (uint64_t(1) << t)) continue; if (!BladeSweepHits(a.pos, ang0, ang1, bladeHalf, m.reach, tg.pos, tg.radius)) continue; if (map.RaycastBlocked(a.pos, tg.pos)) continue; // сквозь стену не рубим m.hitMask |= (uint64_t(1) << t); a.fired = true; const Vec2 dir = Normalized(tg.pos - a.pos, FromAngle(m.dir)); const Vec2 contact = tg.pos - dir * tg.radius; const bool prop = tg.IsProp(); // Через ту же формулу, что и пуля: клинок отличается // формой удара, а не правилами урона. tg.hp -= ResolveDamage(m.curDamage, a.melee.elem, tg.resist, ResistCutOf(tg.status)) * VulnerabilityOf(tg.status); tg.hurtTimer = g_tune.targetHurtTime; KnockTarget(tg, dir, m.curKnock); ApplyElementalHit(targets, t, a.melee.elem, depth, &events); // Щепки вместо крови и мимо счётчика ударов по врагу: // разрубленный ящик не должен красить пол и накручивать // MELEE HITS в метриках. if (prop) { events.Push(CombatEventKind::HIT_PROP, i, contact, dir); } else { events.Push(CombatEventKind::MELEE_HIT, i, contact, dir); ++metrics.meleeHits; } // Инвариант сектора: попадание обязано лежать внутри дуги // ЭТОГО взмаха. Замеряется превышение — оно должно быть ~0. const float off = std::fabs(AngleDiff(m.dir, AngleOf(tg.pos - a.pos))); const float allowed = m.curHalfArc + bladeHalf; metrics.meleeMaxHitExcessDeg = std::max(metrics.meleeMaxHitExcessDeg, std::max(0.0f, off - allowed) / DEG2RAD_F); if (tg.hp <= 0.0f) { tg.alive = false; tg.respawnTimer = tg.RespawnDelay(); if (!prop) ++metrics.targetsKilled; events.Push(prop ? CombatEventKind::PROP_BREAK : CombatEventKind::HIT_KILL, i, tg.pos, dir); } } if (m.phase <= 0.0f) { m.state = MeleeState::RECOVER; m.phase = m.curRecover; } break; } case MeleeState::RECOVER: { m.phase -= dt; if (m.phase <= 0.0f) { m.state = MeleeState::IDLE; m.phase = 0.0f; // Удар доведён до конца — связка продвигается. Окно открыто: // успел ударить снова, пока оно не истекло, — пойдёт // следующий удар серии, не успел — начнёшь сначала. m.comboStep = (m.comboStep + 1) % MELEE_COMBO_STEPS; m.comboTimer = g_tune.meleeComboWindow; } break; } } } } // ----------------------------------------------------------------------------- // Комплекты снаряжения. // // Раздаются ТОЛЬКО в боевом режиме и только здесь. Причина жёсткая: броня несёт // множитель скорости, а на скорости движения меряются критерии 11.4 и 11.5. // Отряд приёмки обязан оставаться голым — тогда цифры сравнимы с прежними. // ----------------------------------------------------------------------------- void Game::EquipKits() { struct Kit { ItemId blade; // если слот держит клинок ItemId gun; // если слот держит ствол ItemId armor; ItemId trinket; ItemId bag[3]; // запасное оружие и расходники }; // Комплект на СЛОТ СТРОЯ, а не на «персонажа»: остриё клина берёт плиту и // тесак, хвост — лёгкую броню и дальний ствол. Состав читается по строю. static const Kit KITS[NUM_AGENTS] = { {ItemId::CLEAVER, ItemId::SHOTGUN, ItemId::PLATE, ItemId::CHARM, {ItemId::BANDAGE, ItemId::NONE, ItemId::NONE}}, {ItemId::KNIFE, ItemId::SMG, ItemId::SHROUD, ItemId::SPURS, {ItemId::PISTOL, ItemId::BANDAGE, ItemId::NONE}}, {ItemId::SABER, ItemId::RIFLE, ItemId::VEST, ItemId::LANTERN, {ItemId::SHOTGUN, ItemId::NONE, ItemId::NONE}}, {ItemId::SABER, ItemId::SHOTGUN, ItemId::VEST, ItemId::CHARM, {ItemId::RIFLE, ItemId::STIMPACK, ItemId::NONE}}, {ItemId::KNIFE, ItemId::MAGNUM, ItemId::JACKET, ItemId::SPURS, {ItemId::SMG, ItemId::BANDAGE, ItemId::NONE}}, }; for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; Inventory* inv = world.Get(a.self); if (!inv) continue; const Kit& kit = KITS[i]; const bool melee = (i < squad.meleeCount); *inv = Inventory{}; inv->weapon = melee ? kit.blade : kit.gun; inv->armor = kit.armor; inv->trinket = kit.trinket; // Запасное уходит в ОБЩУЮ сумку: она одна на отряд. for (int s = 0; s < 3; ++s) if (kit.bag[s] != ItemId::NONE) squad.bag.Take(kit.bag[s], NUM_AGENTS, 0.0f); EquipWeapon(world, a, inv->weapon); a.hp = a.maxHp; } } int Game::AliveAgents() const { int n = 0; for (int i = 0; i < NUM_AGENTS; ++i) if (squad.agents[i].alive) ++n; return n; }