Прототип боевого ядра: отряд, хоррор-слой, ближний бой

Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).

Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.

Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.

Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».

Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.

Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.

Документация — CLAUDE.md как оглавление, содержание в docs/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
z.kirill 2026-08-03 13:00:49 +03:00
commit 7880f275bd
82 changed files with 10859 additions and 0 deletions

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
# Auto detect text files and perform LF normalization
* text=auto
Makefile linguist-generated=true

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# --- сборка ------------------------------------------------------------------
# build/ содержит и объектники, и выкачанный исходник raylib (~200 МБ).
# Всё воспроизводится одной командой: cmake -S . -B build
build/
out/
cmake-build-*/
*.exe
*.pdb
*.ilk
*.obj
*.dsym
main
# --- артефакты dev-режимов ---------------------------------------------------
# squad_proto.exe --shot кладёт скриншот в рабочий каталог, то есть в корень.
# Это отладочные снимки; те, что нужны документации, лежат в docs/.
/*.png
# --- среда -------------------------------------------------------------------
.vs/
.cache/
compile_commands.json
CMakeUserPresets.json

0
.vscode/.gitkeep vendored Normal file
View File

62
.vscode/c_cpp_properties.json vendored Normal file
View File

@ -0,0 +1,62 @@
{
"configurations": [
{
"name": "Win32",
"includePath": [
"C:/raylib/raylib/src/**",
"${workspaceFolder}/**"
],
"defines": [
"_DEBUG",
"UNICODE",
"_UNICODE",
"GRAPHICS_API_OPENGL_33",
"PLATFORM_DESKTOP"
],
"compilerPath": "C:/raylib/w64devkit/bin/gcc.exe",
"cStandard": "c99",
"cppStandard": "c++14",
"intelliSenseMode": "gcc-x64",
"configurationProvider": "ms-vscode.makefile-tools"
},
{
"name": "Mac",
"includePath": [
"${workspaceFolder}/**",
"/opt/homebrew/include"
],
"defines": [
"_DEBUG",
"UNICODE",
"_UNICODE",
"GRAPHICS_API_OPENGL_33",
"PLATFORM_DESKTOP"
],
"macFrameworkPath": [
"/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.13.sdk/System/Library/Frameworks"
],
"compilerPath": "/usr/bin/clang",
"cStandard": "c11",
"cppStandard": "c++14",
"intelliSenseMode": "clang-x64"
},
{
"name": "Linux",
"includePath": [
"/home/linuxbrew/.linuxbrew/include",
"${workspaceFolder}/**"
],
"defines": [
"_DEBUG",
"UNICODE",
"_UNICODE",
"GRAPHICS_API_OPENGL_33",
"PLATFORM_DESKTOP"
],
"cStandard": "c11",
"cppStandard": "c++14",
"intelliSenseMode": "gcc-x64"
}
],
"version": 4
}

40
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,40 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run (Release)",
"type": "cppvsdbg",
"request": "launch",
"program": "${workspaceFolder}/build/bin/squad_proto.exe",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
"environment": [],
"console": "integratedTerminal",
"preLaunchTask": "build release"
},
{
"name": "Debug",
"type": "cppvsdbg",
"request": "launch",
"program": "${workspaceFolder}/build/bin/squad_proto.exe",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
"environment": [],
"console": "integratedTerminal",
"preLaunchTask": "build debug"
},
{
"name": "Acceptance (--accept)",
"type": "cppvsdbg",
"request": "launch",
"program": "${workspaceFolder}/build/bin/squad_proto.exe",
"args": ["--accept"],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"preLaunchTask": "build release"
}
]
}

11
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/*.o": true,
"**/*.exe": true,
}
}

48
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,48 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "cmake configure",
"type": "shell",
"command": "cmake",
"args": ["-S", ".", "-B", "build"],
"problemMatcher": [],
"detail": "Разовая конфигурация; raylib 6.0 подтягивается через FetchContent"
},
{
"label": "build release",
"type": "shell",
"command": "cmake",
"args": ["--build", "build", "--config", "Release", "--parallel"],
"group": { "kind": "build", "isDefault": true },
"problemMatcher": ["$msCompile"],
"detail": "Основная сборка -> build/bin/squad_proto.exe"
},
{
"label": "build debug",
"type": "shell",
"command": "cmake",
"args": ["--build", "build", "--config", "Debug", "--parallel"],
"group": "build",
"problemMatcher": ["$msCompile"]
},
{
"label": "accept",
"type": "shell",
"command": "${workspaceFolder}/build/bin/squad_proto.exe",
"args": ["--accept"],
"dependsOn": "build release",
"problemMatcher": [],
"detail": "Прогнать критерии приёмки из спека (раздел 11)"
},
{
"label": "headless 180s",
"type": "shell",
"command": "${workspaceFolder}/build/bin/squad_proto.exe",
"args": ["--headless", "180"],
"dependsOn": "build release",
"problemMatcher": [],
"detail": "Прогон без окна с метриками"
}
]
}

32
CLAUDE.md Normal file
View File

@ -0,0 +1,32 @@
# CLAUDE.md — оглавление
Этот файл — **только навигация**. Содержание живёт в `docs/`, по одному файлу на тему.
Читай нужный раздел, а не весь проект.
## Документация
| Файл | О чём | Когда открывать |
|---|---|---|
| [docs/00-overview.md](docs/00-overview.md) | что это за прототип, границы задачи | первое знакомство |
| [docs/01-build-and-run.md](docs/01-build-and-run.md) | сборка, запуск, управление, dev-режимы CLI | собрать и потыкать |
| [docs/02-architecture.md](docs/02-architecture.md) | карта модулей, порядок шага симуляции, правила зависимостей | перед любой правкой кода |
| [docs/03-rendering.md](docs/03-rendering.md) | софтверный фреймбуфер, изометрия, сортировка, спрайты | рендер, камера, спрайты |
| [docs/04-world.md](docs/04-world.md) | тайлмап, планировка уровня, DDA-рейкаст, коллизии, мишени | карта, LOS, коллизии |
| [docs/05-squad-ai.md](docs/05-squad-ai.md) | **ядро**: LaneClear, решатель огневых позиций, резерв линий, стрельба | поведение отряда |
| [docs/06-debug-and-tuning.md](docs/06-debug-and-tuning.md) | оверлей F1F4, метрики, runtime-тюнинг, `--set` | отладка и балансировка |
| [docs/07-milestones.md](docs/07-milestones.md) | журнал вех M0M6: что сделано и чем проверено | история и статус |
| [docs/08-decisions.md](docs/08-decisions.md) | отклонения от спека и почему, с замерами | «почему тут не как в ТЗ» |
| [docs/09-acceptance.md](docs/09-acceptance.md) | критерии приёмки (спек 11) и их текущие цифры | приёмка |
| [docs/10-atmosphere.md](docs/10-atmosphere.md) | **хоррор-слой**: свет, туман войны, трассеры, напряжение | картинка и атмосфера |
| [docs/11-melee.md](docs/11-melee.md) | **ближний бой**: сектор удара, три фазы, свой круг, дуга | клинки, классы оружия |
## Быстрые факты
- Исходный спек: [SQUAD_PROTOTYPE_SPEC.md](SQUAD_PROTOTYPE_SPEC.md) — источник истины по правилам.
- Сборка: `cmake -S . -B build && cmake --build build --config Release``build/bin/squad_proto.exe`.
- Самопроверка: `squad_proto.exe --accept` печатает PASS/FAIL по всем измеримым критериям.
- Все числа — в [src/tuning.h](src/tuning.h). Магических констант по коду быть не должно.
- `src/ai/` не знает про рендер, изометрию и ввод. Это правило не нарушать.
- Правило «не бить своих» одно на все классы оружия: у ствола это отрезок,
у клинка — сектор. Ослаблять его нельзя нигде.
- `RenderGame` принимает `const Game&` — эффекты физически не могут влиять на симуляцию.

53
CMakeLists.txt Normal file
View File

@ -0,0 +1,53 @@
cmake_minimum_required(VERSION 3.16)
project(squad_proto CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# ---------------------------------------------------------------------------
# raylib: системная, иначе выкачиваем 6.0 через FetchContent
# ---------------------------------------------------------------------------
find_package(raylib 6 QUIET)
if(NOT raylib_FOUND)
include(FetchContent)
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(BUILD_GAMES OFF CACHE BOOL "" FORCE)
set(CUSTOMIZE_BUILD OFF CACHE BOOL "" FORCE)
FetchContent_Declare(
raylib
URL https://github.com/raysan5/raylib/archive/refs/tags/6.0.tar.gz
DOWNLOAD_EXTRACT_TIMESTAMP TRUE # CMP0135: иначе CMake ругается на таймстемпы
)
FetchContent_MakeAvailable(raylib)
endif()
# ---------------------------------------------------------------------------
# Исходники
# ---------------------------------------------------------------------------
file(GLOB_RECURSE SQUAD_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
add_executable(squad_proto ${SQUAD_SOURCES})
target_include_directories(squad_proto PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(squad_proto PRIVATE raylib)
if(MSVC)
target_compile_options(squad_proto PRIVATE /W4 /utf-8 /permissive-)
else()
target_compile_options(squad_proto PRIVATE -Wall -Wextra)
endif()
if(WIN32)
target_link_libraries(squad_proto PRIVATE winmm)
endif()
# Единый каталог для бинарника при любом генераторе (VS/Ninja/Makefiles)
set_target_properties(squad_proto PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}/bin")

421
Makefile generated Normal file
View File

@ -0,0 +1,421 @@
#**************************************************************************************************
#
# raylib makefile for Desktop platforms, Raspberry Pi, Android and HTML5
#
# Copyright (c) 2013-2019 Ramon Santamaria (@raysan5)
#
# This software is provided "as-is", without any express or implied warranty. In no event
# will the authors be held liable for any damages arising from the use of this software.
#
# Permission is granted to anyone to use this software for any purpose, including commercial
# applications, and to alter it and redistribute it freely, subject to the following restrictions:
#
# 1. The origin of this software must not be misrepresented; you must not claim that you
# wrote the original software. If you use this software in a product, an acknowledgment
# in the product documentation would be appreciated but is not required.
#
# 2. Altered source versions must be plainly marked as such, and must not be misrepresented
# as being the original software.
#
# 3. This notice may not be removed or altered from any source distribution.
#
#**************************************************************************************************
.PHONY: all clean
# Define required raylib variables
PROJECT_NAME ?= game
RAYLIB_VERSION ?= 5.0.0
RAYLIB_PATH ?= ..\..
# Define compiler path on Windows
COMPILER_PATH ?= C:/raylib/w64devkit/bin
# Define default options
# One of PLATFORM_DESKTOP, PLATFORM_RPI, PLATFORM_ANDROID, PLATFORM_WEB
PLATFORM ?= PLATFORM_DESKTOP
# Locations of your newly installed library and associated headers. See ../src/Makefile
# On Linux, if you have installed raylib but cannot compile the examples, check that
# the *_INSTALL_PATH values here are the same as those in src/Makefile or point to known locations.
# To enable system-wide compile-time and runtime linking to libraylib.so, run ../src/$ sudo make install RAYLIB_LIBTYPE_SHARED.
# To enable compile-time linking to a special version of libraylib.so, change these variables here.
# To enable runtime linking to a special version of libraylib.so, see EXAMPLE_RUNTIME_PATH below.
# If there is a libraylib in both EXAMPLE_RUNTIME_PATH and RAYLIB_INSTALL_PATH, at runtime,
# the library at EXAMPLE_RUNTIME_PATH, if present, will take precedence over the one at RAYLIB_INSTALL_PATH.
# RAYLIB_INSTALL_PATH should be the desired full path to libraylib. No relative paths.
DESTDIR ?= /usr/local
RAYLIB_INSTALL_PATH ?= $(DESTDIR)/lib
# RAYLIB_H_INSTALL_PATH locates the installed raylib header and associated source files.
RAYLIB_H_INSTALL_PATH ?= $(DESTDIR)/include
# Library type used for raylib: STATIC (.a) or SHARED (.so/.dll)
RAYLIB_LIBTYPE ?= STATIC
# Build mode for project: DEBUG or RELEASE
BUILD_MODE ?= RELEASE
# Use external GLFW library instead of rglfw module
# TODO: Review usage on Linux. Target version of choice. Switch on -lglfw or -lglfw3
USE_EXTERNAL_GLFW ?= FALSE
# Use Wayland display server protocol on Linux desktop
# by default it uses X11 windowing system
USE_WAYLAND_DISPLAY ?= FALSE
# Determine PLATFORM_OS in case PLATFORM_DESKTOP selected
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
# No uname.exe on MinGW!, but OS=Windows_NT on Windows!
# ifeq ($(UNAME),Msys) -> Windows
ifeq ($(OS),Windows_NT)
PLATFORM_OS=WINDOWS
export PATH := $(COMPILER_PATH):$(PATH)
else
UNAMEOS=$(shell uname)
ifeq ($(UNAMEOS),Linux)
PLATFORM_OS=LINUX
endif
ifeq ($(UNAMEOS),FreeBSD)
PLATFORM_OS=BSD
endif
ifeq ($(UNAMEOS),OpenBSD)
PLATFORM_OS=BSD
endif
ifeq ($(UNAMEOS),NetBSD)
PLATFORM_OS=BSD
endif
ifeq ($(UNAMEOS),DragonFly)
PLATFORM_OS=BSD
endif
ifeq ($(UNAMEOS),Darwin)
PLATFORM_OS=OSX
endif
endif
endif
ifeq ($(PLATFORM),PLATFORM_RPI)
UNAMEOS=$(shell uname)
ifeq ($(UNAMEOS),Linux)
PLATFORM_OS=LINUX
endif
endif
# RAYLIB_PATH adjustment for different platforms.
# If using GNU make, we can get the full path to the top of the tree. Windows? BSD?
# Required for ldconfig or other tools that do not perform path expansion.
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
ifeq ($(PLATFORM_OS),LINUX)
RAYLIB_PREFIX ?= ..
RAYLIB_PATH = $(realpath $(RAYLIB_PREFIX))
endif
endif
# Default path for raylib on Raspberry Pi, if installed in different path, update it!
# This is not currently used by src/Makefile. Not sure of its origin or usage. Refer to wiki.
# TODO: update install: target in src/Makefile for RPI, consider relation to LINUX.
ifeq ($(PLATFORM),PLATFORM_RPI)
RAYLIB_PATH ?= /home/pi/raylib
endif
ifeq ($(PLATFORM),PLATFORM_WEB)
# Emscripten required variables
EMSDK_PATH ?= C:/emsdk
EMSCRIPTEN_VERSION ?= 1.38.31
CLANG_VERSION = e$(EMSCRIPTEN_VERSION)_64bit
PYTHON_VERSION = 2.7.13.1_64bit\python-2.7.13.amd64
NODE_VERSION = 8.9.1_64bit
export PATH = $(EMSDK_PATH);$(EMSDK_PATH)\clang\$(CLANG_VERSION);$(EMSDK_PATH)\node\$(NODE_VERSION)\bin;$(EMSDK_PATH)\python\$(PYTHON_VERSION);$(EMSDK_PATH)\emscripten\$(EMSCRIPTEN_VERSION);C:\raylib\MinGW\bin:$$(PATH)
EMSCRIPTEN = $(EMSDK_PATH)\emscripten\$(EMSCRIPTEN_VERSION)
endif
# Define raylib release directory for compiled library.
# RAYLIB_RELEASE_PATH points to provided binaries or your freshly built version
RAYLIB_RELEASE_PATH ?= $(RAYLIB_PATH)/src
# EXAMPLE_RUNTIME_PATH embeds a custom runtime location of libraylib.so or other desired libraries
# into each example binary compiled with RAYLIB_LIBTYPE=SHARED. It defaults to RAYLIB_RELEASE_PATH
# so that these examples link at runtime with your version of libraylib.so in ../release/libs/linux
# without formal installation from ../src/Makefile. It aids portability and is useful if you have
# multiple versions of raylib, have raylib installed to a non-standard location, or want to
# bundle libraylib.so with your game. Change it to your liking.
# NOTE: If, at runtime, there is a libraylib.so at both EXAMPLE_RUNTIME_PATH and RAYLIB_INSTALL_PATH,
# The library at EXAMPLE_RUNTIME_PATH, if present, will take precedence over RAYLIB_INSTALL_PATH,
# Implemented for LINUX below with CFLAGS += -Wl,-rpath,$(EXAMPLE_RUNTIME_PATH)
# To see the result, run readelf -d core/core_basic_window; looking at the RPATH or RUNPATH attribute.
# To see which libraries a built example is linking to, ldd core/core_basic_window;
# Look for libraylib.so.1 => $(RAYLIB_INSTALL_PATH)/libraylib.so.1 or similar listing.
EXAMPLE_RUNTIME_PATH ?= $(RAYLIB_RELEASE_PATH)
# Define default C compiler: gcc
# NOTE: define g++ compiler if using C++
CC = g++
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
ifeq ($(PLATFORM_OS),OSX)
# OSX default compiler
CC = clang++
endif
ifeq ($(PLATFORM_OS),BSD)
# FreeBSD, OpenBSD, NetBSD, DragonFly default compiler
CC = clang
endif
endif
ifeq ($(PLATFORM),PLATFORM_RPI)
ifeq ($(USE_RPI_CROSS_COMPILER),TRUE)
# Define RPI cross-compiler
#CC = armv6j-hardfloat-linux-gnueabi-gcc
CC = $(RPI_TOOLCHAIN)/bin/arm-linux-gnueabihf-gcc
endif
endif
ifeq ($(PLATFORM),PLATFORM_WEB)
# HTML5 emscripten compiler
# WARNING: To compile to HTML5, code must be redesigned
# to use emscripten.h and emscripten_set_main_loop()
CC = emcc
endif
# Define default make program: Mingw32-make
MAKE = mingw32-make
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
ifeq ($(PLATFORM_OS),LINUX)
MAKE = make
endif
ifeq ($(PLATFORM_OS),OSX)
MAKE = make
endif
endif
# Define compiler flags:
# -O0 defines optimization level (no optimization, better for debugging)
# -O1 defines optimization level
# -g include debug information on compilation
# -s strip unnecessary data from build -> do not use in debug builds
# -Wall turns on most, but not all, compiler warnings
# -std=c99 defines C language mode (standard C from 1999 revision)
# -std=gnu99 defines C language mode (GNU C from 1999 revision)
# -Wno-missing-braces ignore invalid warning (GCC bug 53119)
# -D_DEFAULT_SOURCE use with -std=c99 on Linux and PLATFORM_WEB, required for timespec
CFLAGS += -Wall -std=c++14 -D_DEFAULT_SOURCE -Wno-missing-braces
ifeq ($(BUILD_MODE),DEBUG)
CFLAGS += -g -O0
else
CFLAGS += -s -O1
endif
# Additional flags for compiler (if desired)
#CFLAGS += -Wextra -Wmissing-prototypes -Wstrict-prototypes
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
ifeq ($(PLATFORM_OS),WINDOWS)
# resource file contains windows executable icon and properties
# -Wl,--subsystem,windows hides the console window
CFLAGS += $(RAYLIB_PATH)/src/raylib.rc.data
endif
ifeq ($(PLATFORM_OS),LINUX)
ifeq ($(RAYLIB_LIBTYPE),STATIC)
CFLAGS += -D_DEFAULT_SOURCE
endif
ifeq ($(RAYLIB_LIBTYPE),SHARED)
# Explicitly enable runtime link to libraylib.so
CFLAGS += -Wl,-rpath,$(EXAMPLE_RUNTIME_PATH)
endif
endif
endif
ifeq ($(PLATFORM),PLATFORM_RPI)
CFLAGS += -std=gnu99
endif
ifeq ($(PLATFORM),PLATFORM_WEB)
# -Os # size optimization
# -O2 # optimization level 2, if used, also set --memory-init-file 0
# -s USE_GLFW=3 # Use glfw3 library (context/input management)
# -s ALLOW_MEMORY_GROWTH=1 # to allow memory resizing -> WARNING: Audio buffers could FAIL!
# -s TOTAL_MEMORY=16777216 # to specify heap memory size (default = 16MB)
# -s USE_PTHREADS=1 # multithreading support
# -s WASM=0 # disable Web Assembly, emitted by default
# -s EMTERPRETIFY=1 # enable emscripten code interpreter (very slow)
# -s EMTERPRETIFY_ASYNC=1 # support synchronous loops by emterpreter
# -s FORCE_FILESYSTEM=1 # force filesystem to load/save files data
# -s ASSERTIONS=1 # enable runtime checks for common memory allocation errors (-O1 and above turn it off)
# --profiling # include information for code profiling
# --memory-init-file 0 # to avoid an external memory initialization code file (.mem)
# --preload-file resources # specify a resources folder for data compilation
CFLAGS += -Os -s USE_GLFW=3 -s TOTAL_MEMORY=16777216 --preload-file resources
ifeq ($(BUILD_MODE), DEBUG)
CFLAGS += -s ASSERTIONS=1 --profiling
endif
# Define a custom shell .html and output extension
CFLAGS += --shell-file $(RAYLIB_PATH)/src/shell.html
EXT = .html
endif
# Define include paths for required headers
# NOTE: Several external required libraries (stb and others)
INCLUDE_PATHS = -I. -I$(RAYLIB_PATH)/src -I$(RAYLIB_PATH)/src/external
ifneq ($(wildcard /opt/homebrew/include/.*),)
INCLUDE_PATHS += -I/opt/homebrew/include
endif
# Define additional directories containing required header files
ifeq ($(PLATFORM),PLATFORM_RPI)
# RPI required libraries
INCLUDE_PATHS += -I/opt/vc/include
INCLUDE_PATHS += -I/opt/vc/include/interface/vmcs_host/linux
INCLUDE_PATHS += -I/opt/vc/include/interface/vcos/pthreads
endif
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
ifeq ($(PLATFORM_OS),BSD)
# Consider -L$(RAYLIB_H_INSTALL_PATH)
INCLUDE_PATHS += -I/usr/local/include
endif
ifeq ($(PLATFORM_OS),LINUX)
# Reset everything.
# Precedence: immediately local, installed version, raysan5 provided libs -I$(RAYLIB_H_INSTALL_PATH) -I$(RAYLIB_PATH)/release/include
INCLUDE_PATHS = -I$(RAYLIB_H_INSTALL_PATH) -isystem. -isystem$(RAYLIB_PATH)/src -isystem$(RAYLIB_PATH)/release/include -isystem$(RAYLIB_PATH)/src/external
endif
endif
# Define library paths containing required libs.
LDFLAGS = -L.
ifneq ($(wildcard $(RAYLIB_RELEASE_PATH)/.*),)
LDFLAGS += -L$(RAYLIB_RELEASE_PATH)
endif
ifneq ($(wildcard $(RAYLIB_PATH)/src/.*),)
LDFLAGS += -L$(RAYLIB_PATH)/src
endif
ifneq ($(wildcard /opt/homebrew/lib/.*),)
LDFLAGS += -L/opt/homebrew/lib
endif
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
ifeq ($(PLATFORM_OS),BSD)
# Consider -L$(RAYLIB_INSTALL_PATH)
LDFLAGS += -L. -Lsrc -L/usr/local/lib
endif
ifeq ($(PLATFORM_OS),LINUX)
# Reset everything.
# Precedence: immediately local, installed version, raysan5 provided libs
LDFLAGS = -L. -L$(RAYLIB_INSTALL_PATH) -L$(RAYLIB_RELEASE_PATH)
endif
endif
ifeq ($(PLATFORM),PLATFORM_RPI)
LDFLAGS += -L/opt/vc/lib
endif
# Define any libraries required on linking
# if you want to link libraries (libname.so or libname.a), use the -lname
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
ifeq ($(PLATFORM_OS),WINDOWS)
# Libraries for Windows desktop compilation
# NOTE: WinMM library required to set high-res timer resolution
LDLIBS = -lraylib -lopengl32 -lgdi32 -lwinmm
# Required for physac examples
#LDLIBS += -static -lpthread
endif
ifeq ($(PLATFORM_OS),LINUX)
# Libraries for Debian GNU/Linux desktop compiling
# NOTE: Required packages: libegl1-mesa-dev
LDLIBS = -lraylib -lGL -lm -lpthread -ldl -lrt
# On X11 requires also below libraries
LDLIBS += -lX11
# NOTE: It seems additional libraries are not required any more, latest GLFW just dlopen them
#LDLIBS += -lXrandr -lXinerama -lXi -lXxf86vm -lXcursor
# On Wayland windowing system, additional libraries requires
ifeq ($(USE_WAYLAND_DISPLAY),TRUE)
LDLIBS += -lwayland-client -lwayland-cursor -lwayland-egl -lxkbcommon
endif
# Explicit link to libc
ifeq ($(RAYLIB_LIBTYPE),SHARED)
LDLIBS += -lc
endif
endif
ifeq ($(PLATFORM_OS),OSX)
# Libraries for OSX 10.9 desktop compiling
# NOTE: Required packages: libopenal-dev libegl1-mesa-dev
LDLIBS = -lraylib -framework OpenGL -framework OpenAL -framework Cocoa -framework IOKit
endif
ifeq ($(PLATFORM_OS),BSD)
# Libraries for FreeBSD, OpenBSD, NetBSD, DragonFly desktop compiling
# NOTE: Required packages: mesa-libs
LDLIBS = -lraylib -lGL -lpthread -lm
# On XWindow requires also below libraries
LDLIBS += -lX11 -lXrandr -lXinerama -lXi -lXxf86vm -lXcursor
endif
ifeq ($(USE_EXTERNAL_GLFW),TRUE)
# NOTE: It could require additional packages installed: libglfw3-dev
LDLIBS += -lglfw
endif
endif
ifeq ($(PLATFORM),PLATFORM_RPI)
# Libraries for Raspberry Pi compiling
# NOTE: Required packages: libasound2-dev (ALSA)
LDLIBS = -lraylib -lbrcmGLESv2 -lbrcmEGL -lpthread -lrt -lm -lbcm_host -ldl
endif
ifeq ($(PLATFORM),PLATFORM_WEB)
# Libraries for web (HTML5) compiling
LDLIBS = $(RAYLIB_RELEASE_PATH)/libraylib.bc
endif
# Define a recursive wildcard function
rwildcard=$(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2) $(filter $(subst *,%,$2),$d))
# Define all source files required
SRC_DIR = src
OBJ_DIR = obj
# Define all object files from source files
SRC = $(call rwildcard, *.c, *.h)
#OBJS = $(SRC:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
OBJS ?= main.c
# For Android platform we call a custom Makefile.Android
ifeq ($(PLATFORM),PLATFORM_ANDROID)
MAKEFILE_PARAMS = -f Makefile.Android
export PROJECT_NAME
export SRC_DIR
else
MAKEFILE_PARAMS = $(PROJECT_NAME)
endif
# Default target entry
# NOTE: We call this Makefile target or Makefile.Android target
all:
$(MAKE) $(MAKEFILE_PARAMS)
# Project target defined by PROJECT_NAME
$(PROJECT_NAME): $(OBJS)
$(CC) -o $(PROJECT_NAME)$(EXT) $(OBJS) $(CFLAGS) $(INCLUDE_PATHS) $(LDFLAGS) $(LDLIBS) -D$(PLATFORM)
# Compile source files
# NOTE: This pattern will compile every module defined on $(OBJS)
#%.o: %.c
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
$(CC) -c $< -o $@ $(CFLAGS) $(INCLUDE_PATHS) -D$(PLATFORM)
# Clean everything
clean:
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
ifeq ($(PLATFORM_OS),WINDOWS)
del *.o *.exe /s
endif
ifeq ($(PLATFORM_OS),LINUX)
find -type f -executable | xargs file -i | grep -E 'x-object|x-archive|x-sharedlib|x-executable' | rev | cut -d ':' -f 2- | rev | xargs rm -fv
endif
ifeq ($(PLATFORM_OS),OSX)
find . -type f -perm +ugo+x -delete
rm -f *.o
endif
endif
ifeq ($(PLATFORM),PLATFORM_RPI)
find . -type f -executable -delete
rm -fv *.o
endif
ifeq ($(PLATFORM),PLATFORM_WEB)
del *.o *.html *.js
endif
@echo Cleaning done

60
README.md Normal file
View File

@ -0,0 +1,60 @@
# Squad prototype — тактический отряд с авто-огнём
Технический прототип боевого ядра 2D-изометрического sci-fi horror roguelite.
Отряд из 5 агентов сам расставляется так, чтобы стрелять и **не задевать своих**.
Двое из пяти работают клинком: у ближнего боя то же правило в угловой форме —
свой в секторе замаха, значит удара не будет.
![боевой кадр с оверлеем](docs/combat.png)
C++17 · raylib 6.0 · CMake · **софтверный рендер** (вся сцена — ручная запись
пикселей в свой фреймбуфер 480×270, апскейл ×3).
## Быстрый старт
```bash
cmake -S . -B build # разово, тянет raylib 6.0
cmake --build build --config Release --parallel
build/bin/squad_proto.exe
```
В VS Code — просто `F5`.
`WASD` двигает якорь отряда, `Space` — ручное прицеливание, `1/2/3` — строй,
`F1..F4` — оверлей. Стрельба всегда автоматическая. Полный список — в документации.
## Самопроверка
```bash
build/bin/squad_proto.exe --accept # критерии приёмки, PASS/FAIL
build/bin/squad_proto.exe --headless 300 # прогон без окна + метрики
```
Симуляция детерминирована и не зависит от рендера, поэтому критерии проверяются
цифрами: `FRIENDLY_HITS = 0` за 3 минуты боя смешанным отрядом, `FIRE UPTIME`
100 % в стоячем бою, расхождение веером за 0.18 с, 81 взмах клинком без единого
задетого своего, шаг симуляции 2 мкс при бюджете кадра 16.6 мс.
## Документация
Оглавление — [CLAUDE.md](CLAUDE.md). Модульная документация в [docs/](docs/):
- [00-overview](docs/00-overview.md) — что это и чего тут нет
- [01-build-and-run](docs/01-build-and-run.md) — сборка, управление, dev-режимы
- [02-architecture](docs/02-architecture.md) — модули, порядок шага, правила зависимостей
- [03-rendering](docs/03-rendering.md) — фреймбуфер, изометрия, спрайты
- [04-world](docs/04-world.md) — карта, рейкаст, коллизии, мишени
- [05-squad-ai](docs/05-squad-ai.md) — **ядро**: линии огня, решатель, резервирование
- [06-debug-and-tuning](docs/06-debug-and-tuning.md) — оверлей, метрики, тюнинг
- [07-milestones](docs/07-milestones.md) — журнал вех
- [08-decisions](docs/08-decisions.md) — отклонения от спека и почему
- [09-acceptance](docs/09-acceptance.md) — критерии приёмки и цифры
- [10-atmosphere](docs/10-atmosphere.md) — свет, туман войны, трассеры, напряжение
- [11-melee](docs/11-melee.md) — **ближний бой**: сектор удара, три фазы, свой круг
Исходное ТЗ: [SQUAD_PROTOTYPE_SPEC.md](SQUAD_PROTOTYPE_SPEC.md).
---
Проект вырос из шаблона [Raylib-CPP Starter Template for VS Code](https://youtu.be/acvgbKRaxDI);
от шаблона остался только `Makefile`, который больше не используется — сборка через CMake.

313
SQUAD_PROTOTYPE_SPEC.md Normal file
View File

@ -0,0 +1,313 @@
# Прототип: тактический отряд с авто-огнём (изометрия, software rendering)
Ты делаешь **технический прототип боевого ядра** для 2D-изометрического sci-fi horror roguelite / incremental ARPG.
Цель прототипа — **только одно**: доказать, что отряд из 5 агентов ведёт себя как «умная текучая сущность», которая сама расставляется так, чтобы стрелять и не задевать своих.
Всё остальное (лут, прогрессия, враги-ИИ, уровни) — вне рамок. Не добавляй.
---
## 1. Технические ограничения
- **C++17**, **raylib 5.x**, **CMake** (raylib через `FetchContent`, если не найден в системе).
- Никаких других зависимостей. Никакого ECS-фреймворка, никакой физдвижка.
- **Software rendering.** Вся отрисовка сцены — ручная запись пикселей в собственный фреймбуфер (`uint32_t*`, RGBA8). Ни одного вызова raylib-примитивов для игровой сцены.
- Из raylib используются только: окно, ввод, таймер, `UpdateTexture` + `DrawTexturePro` для финального блита фреймбуфера на экран (фильтрация `TEXTURE_FILTER_POINT`).
- Исключение: **debug-оверлей** можно рисовать через `DrawText`/`DrawLine` поверх уже отмасштабированного кадра. Это dev-only, отключается по F1.
- **Внутреннее разрешение 480×270**, окно 1440×810 (целочисленный ×3 апскейл). Разрешение и множитель — константы.
- **Fixed timestep симуляции 60 Гц**, накопитель времени, рендер с интерполяцией позиций. Симуляция детерминирована.
- Однопоточно. Читаемость важнее производительности, но 5 агентов + 12 мишеней + 200 пуль должны идти в 60 fps без проблем.
---
## 2. Ассеты
**Никаких файлов.** Все спрайты генерируются процедурно в код при старте (пишешь пиксели в буфер):
- `tile_floor` — ромб 32×16, 2 варианта оттенка (шахматка), тёмно-серый металл.
- `tile_wall` — блок 32×32 (ромб + вертикальные грани), заметно светлее пола.
- `agent` — 10×14, капсула с обводкой, 5 цветовых вариантов (по одному на агента).
- `target` — 12×16, тускло-красная капсула с обводкой.
- `bullet` — 2×2 точка.
Спрайты хранятся как `Sprite { int w, h; std::vector<uint32_t> px; }` с альфа-тестом (0 = прозрачно), без блендинга.
---
## 3. Координаты и рендер
**Критично: вся симуляция — в декартовом мире (top-down), изометрия существует только в рендерере.**
Никакой iso-математики в логике, ИИ или коллизиях.
- Мир в тайлах, `float` координаты. Тайл = 1.0 юнит.
- Проекция: `sx = (wx - wy) * 16`, `sy = (wx + wy) * 8` (тайл 32×16), плюс смещение камеры.
- Камера следует за якорем отряда со сглаживанием, округляется до целых пикселей (чтобы не дрожало).
- Сортировка по глубине: все видимые сущности и стены в один список, сортировка по `(wx + wy)`, потом блит.
- Пол рисуется первым сплошным проходом по видимой области карты.
---
## 4. Уровень
Один захардкоженный тайлмап ~48×48, генерируется в коде:
- открытая арена в центре;
- **колонны/огрызки стен по всей карте** — они обязаны быть, без них не проверить LOS и обход;
- пара узких проходов (шириной 2 тайла) — там правило «не стрелять сквозь своих» упирается в геометрию, это важный тест-кейс;
- по периметру — сплошная стена.
Тайл: `EMPTY` (проходим, прозрачен) / `WALL` (непроходим, блокирует LOS и пули).
**Мишени:** 12 штук, статичные, разбросаны кучками (по 1, по 3, по 5). У мишени есть HP (например, 500) и она **респавнится через 3 сек** после смерти на том же месте — прототип должен работать бесконечно. Мишени не атакуют и не двигаются (на этом этапе это шум).
---
## 5. Управление (только клавиатура)
| Клавиша | Действие |
|---|---|
| `WASD` | движение якоря отряда (в **экранных** осях, не мировых — иначе неинтуитивно) |
| `Shift` (удерж.) | медленный шаг |
| `Space` | переключить режим ручного прицеливания |
| `←/→` | в ручном режиме — поворот вектора прицела |
| `Tab` | сменить назначенную цель |
| `1/2/3` | пресет строя: клин / линия / кольцо |
| `F1` | debug-оверлей вкл/выкл |
| `F2` | показ линий огня |
| `F3` | показ кандидатов позиций и их оценок |
| `F4` | метрики |
| `R` | сброс уровня |
| `Esc` | выход |
Стрельба **всегда автоматическая**. Игрок не нажимает «огонь» никогда, ни в одном режиме.
---
## 6. ЯДРО: поведение отряда
Это главная часть задания. Ниже — точная спецификация, реализуй её как **чистые, тестируемые функции**, отдельно от рендера.
### 6.1 Сущности
```cpp
struct Agent {
Vec2 pos, vel;
float facing; // радианы, куда смотрит ствол
float bodyRadius = 0.28f;
int slotIndex; // место в строю
AgentState state;
int currentTargetId; // -1 = нет
Vec2 postPos; // куда агент решил встать (огневая позиция)
float commitTimer; // блокировка перерешивания
float solveTimer; // стаггер решателя
Weapon weapon;
};
struct Weapon {
float range = 9.0f;
float fireInterval = 0.35f;
float cooldown;
float laneHalfWidth = 0.10f; // «толщина» траектории пули
float damage = 12.0f;
};
```
### 6.2 Состояния отряда
- `ADVANCING` — есть ввод движения.
- `HOLDING` — ввода нет дольше `0.15 сек`.
### 6.3 Проверка линии огня (сердце всей механики)
```
bool LaneClear(shooter, targetPos, allAgents, tilemap):
seg = Segment(shooter.muzzle(), targetPos)
// 1. свои
for each ally in allAgents, ally != shooter:
d = DistancePointToSegment(ally.pos, seg)
if d < ally.bodyRadius + shooter.weapon.laneHalfWidth + SAFETY_MARGIN:
return false
// 2. геометрия
if TileRaycastBlocked(seg, tilemap):
return false
// 3. дистанция
if seg.length() > shooter.weapon.range:
return false
return true
```
`SAFETY_MARGIN = 0.15f` (тюнится). `muzzle()` — точка в `0.3` юнита от центра агента по `facing`.
Рейкаст по тайлам — Amanatides & Woo (DDA), без выделений памяти.
### 6.4 Логика в состоянии `ADVANCING`
- Каждый агент движется к своему **слоту строя** относительно якоря, строй ориентирован по направлению движения.
- Стрельба **оппортунистическая**: стреляет только тот, у кого прямо сейчас `LaneClear` до какой-либо цели в радиусе. Остальные просто бегут.
- Приоритет цели у бегущего: ближайшая, до которой линия чиста.
- **Никакого перестроения ради огня** в этом состоянии — отряд едет туда, куда сказал игрок.
- Работает уклонение от чужих линий (см. 6.6) — агент, влезший в чужой сектор обстрела, смещается.
### 6.5 Логика в состоянии `HOLDING` — решатель огневых позиций
Запускается **не каждый кадр**: у каждого агента свой `solveTimer` (интервал `0.25 сек`), фазы сдвинуты так, чтобы за кадр решал максимум 1 агент.
Условие запуска: у агента **нет** валидной линии огня ни к одной живой цели в радиусе.
**Кандидаты позиций:**
- текущая позиция (для сравнения);
- 3 кольца радиусами `0.8 / 1.6 / 2.4` юнита × 12 направлений = 36 точек.
Отбрасываются кандидаты: в стене, ближе `0.55` юнита к другому агенту, вне карты.
**Оценка кандидата:**
```
score = 0
if LaneClearFrom(candidate, bestTarget): score += 100
score += 25 * (число целей, простреливаемых из candidate) / totalTargets
score -= 35 * (число союзников, чью текущую валидную линию перекрывает candidate)
score -= 9 * distance(candidate, agent.pos)
score -= 14 * max(0, distance(candidate, anchor) - COHESION_RADIUS)
if distance(candidate, ближайшая цель) < MIN_ENGAGE_DIST: score -= 60
```
**Коммит с гистерезисом (обязательно, иначе будет дёрганье):**
```
if bestScore > currentPositionScore + HYSTERESIS(15.0):
agent.postPos = bestCandidate
agent.commitTimer = 0.6f // не перерешивать это время
```
`COHESION_RADIUS = 3.5`, `MIN_ENGAGE_DIST = 2.0`.
Пока `commitTimer > 0` — агент идёт к `postPos` и не пересчитывает.
### 6.6 Резервирование линий и расступание («текучесть»)
Каждый кадр собирается список **активных линий огня** — сегментов от стреляющих агентов к их целям.
Любой агент, чьё тело попадает в чужую активную линию (`dist < bodyRadius + laneHalfWidth + SAFETY_MARGIN`), получает **steering-импульс перпендикулярно линии**, в сторону ближайшего края. Сила импульса растёт при приближении к оси.
Это работает в **обоих** состояниях отряда и даёт главное ощущение: отряд сам «расплывается», освобождая сектора обстрела, без единой явной команды.
### 6.7 Движение агента (слои, складываются в таком порядке)
1. `seek` к целевой точке (слот строя или `postPos`);
2. `separation` от союзников (радиус `0.7`);
3. `laneAvoidance` (6.6) — **вес выше, чем у separation**;
4. `wallAvoidance` + скольжение вдоль стен при коллизии;
5. клампинг по максимальной скорости, интеграция, разрешение коллизий с тайлами (circle-vs-AABB, push-out).
Скорость: `3.2` юнита/сек, `1.6` на Shift.
### 6.8 Ручное прицеливание (`Space`)
- Игрок вращает вектор прицела стрелками.
- **Назначенная цель** — ближайшая мишень в конусе ±20° от вектора прицела. `Tab` циклит между целями в конусе.
- Все агенты приоритезируют назначенную цель.
- **Правила 6.36.6 не меняются вообще.** Агент без чистой линии до назначенной цели:
- в `HOLDING` — перестраивается через решатель, целью решателя становится назначенная цель;
- в `ADVANCING` — не стреляет по ней; стреляет по вторичным целям, только если включён флаг `freeFireSecondary` (по умолчанию `true`).
- Ручной режим **никогда** не позволяет стрелять сквозь своих. Никаких исключений.
### 6.9 Пули
Настоящие снаряды, не хитскан: скорость `22` юнита/сек, коллизия с тайлами и со **всеми** капсулами, включая союзников.
**`friendlyFire = true` всегда.** Дружественный урон должен быть физически возможен — иначе прототип ничего не доказывает. Счётчик попаданий по своим — главная метрика корректности.
---
## 7. Debug-оверлей
По `F1`, поверх кадра:
- состояние отряда (`ADVANCING` / `HOLDING`), режим прицеливания;
- над каждым агентом: индекс, состояние, id цели;
- `F2`: линии огня — **зелёная** (чистая, стреляет), **жёлтая** (цель есть, линия перекрыта своим), **красная** (перекрыта стеной);
- `F3`: кандидаты последнего решателя точками, цвет = оценка (синий низкая → белый высокая), выбранный кандидат обведён;
- якорь отряда, радиус когезии, слоты строя;
- `F4`, метрики:
- **`FRIENDLY_HITS`** — накопительный счётчик попаданий по своим;
- **`FIRE UPTIME`** — % времени, когда агент имел валидную линию (за последние 10 сек, по каждому агенту и среднее);
- **`SETTLE TIME`** — время от перехода в `HOLDING` до момента, когда ≥4 агентов получили линию огня;
- fps, время симуляции на кадр.
---
## 8. Тюнинг
Все числа выше — в **одной структуре** `Tuning` в `tuning.h`, ни одного магического числа по коду.
Плюс runtime-подкрутка: `[` / `]` выбирают параметр, `-` / `=` меняют значение, текущее значение видно в оверлее. Это нужно, чтобы я сам почувствовал баланс.
---
## 9. Структура проекта
```
CMakeLists.txt
src/
main.cpp — окно, главный цикл, fixed timestep
tuning.h — все константы
core/math.h — Vec2, сегменты, DistPointSegment, углы
render/framebuffer.h/.cpp — фреймбуфер, clear, blit, линия, круг, блит на экран
render/sprites.h/.cpp — процедурная генерация спрайтов
render/iso.h — проекция мир↔экран, сортировка глубины
world/tilemap.h/.cpp — карта, DDA-рейкаст, коллизии
sim/agent.h/.cpp — агент, движение, steering
sim/squad.h/.cpp — якорь, строй, состояния отряда
sim/target.h/.cpp
sim/bullet.h/.cpp
ai/firing_solver.h/.cpp — LaneClear, оценка кандидатов, коммит
ai/lane_registry.h/.cpp — активные линии, расступание
debug/overlay.h/.cpp
debug/metrics.h/.cpp
```
`ai/` не должен знать ничего о рендере и об изометрии. Вообще.
---
## 10. Порядок работы
Делай по вехам, **после каждой — собери и запусти**, убедись что работает, потом иди дальше. Не пиши всё сразу.
- **M0** — окно, фреймбуфер, апскейл, изометрическая сетка на экране.
- **M1** — тайлмап, стены, камера, сортировка по глубине.
- **M2** — 5 агентов, движение якоря, строй, separation, коллизии со стенами.
- **M3** — мишени, DDA-рейкаст, `LaneClear`, авто-огонь, пули, дружественный урон, счётчик `FRIENDLY_HITS`.
- **M4** — решатель огневых позиций, гистерезис, коммит, расступание из чужих линий. **Ключевая веха.**
- **M5** — ручное прицеливание, назначенная цель, `Tab`.
- **M6** — полный debug-оверлей, метрики, runtime-тюнинг.
---
## 11. Критерии приёмки
Прототип готов, когда **все** пункты выполняются:
1. Отряд стоит в куче, игрок останавливается → **за ≤1.5 сек** минимум 4 из 5 агентов имеют чистую линию огня. Видно, как они расходятся веером.
2. **`FRIENDLY_HITS` = 0** за 3 минуты непрерывного боя во всех режимах, включая узкие проходы.
3. При движении отряда стреляют только те, у кого чистая линия; остальные не палят в спину союзникам. Визуально это заметно — часть агентов молчит.
4. Агент, оказавшийся на чужой линии огня, **сам отходит вбок** в течение ~0.3 сек.
5. Нет дёрганья: агент не переключает `postPos` чаще чем раз в `0.6` сек, позиции не осциллируют между двумя точками.
6. В узком проходе (2 тайла) отряд **самостоятельно вытягивается в колонну**, стреляет передний, задние подтягиваются и не стреляют.
7. В ручном режиме те же правила: агент без линии до назначенной цели перестраивается (стоя) либо молчит (в движении), но **никогда** не стреляет сквозь своего.
8. `FIRE UPTIME` в стоячем бою на открытой арене ≥ 80%.
9. Стабильные 60 fps.
10. Вся сцена нарисована собственным софтверным растеризатором; в игровой отрисовке нет raylib-примитивов.
---
## 12. Чего не делать
- Не добавлять врагов с ИИ, атаками, лут, UI, меню, звук, партиклы, освещение, туман войны.
- Не подключать сторонние библиотеки.
- Не писать ECS, не абстрагировать преждевременно. Простые структуры и вектора.
- Не «улучшать» правила стрельбы по своему усмотрению — раздел 6 реализуется как написано. Если считаешь, что где-то ошибка — сначала скажи, потом делай.

57
docs/00-overview.md Normal file
View File

@ -0,0 +1,57 @@
# 00 — Что это
Технический прототип **боевого ядра** 2D-изометрического sci-fi horror roguelite.
Доказывает ровно одну вещь:
> отряд из 5 агентов ведёт себя как «умная текучая сущность», которая сама
> расставляется так, чтобы атаковать и не задевать своих.
Всё остальное — вне рамок и намеренно не сделано.
## Что есть
- 5 агентов, один диск отряда (якорь), три пресета строя (клин / линия / кольцо).
- Полностью **автоматическая** атака: игрок не нажимает «огонь» никогда.
- Проверка линии огня по своим, по стенам и по дальности (спек 6.3).
- Решатель огневых позиций с гистерезисом и коммитом (спек 6.5).
- Резервирование линий огня и расступание из чужих секторов (спек 6.6).
- Настоящие пули-снаряды, дружественный урон физически возможен (спек 6.9).
- Ручное прицеливание с назначенной целью и `Tab` (спек 6.8).
- **Ближний бой**: второй класс оружия с сектором замаха вместо линии огня,
тремя фазами удара и своим, большим кругом вокруг диска
([11-melee.md](11-melee.md)). В спеке его нет.
- Хоррор-слой: свет, туман войны, трассеры, напряжение
([10-atmosphere.md](10-atmosphere.md)). В спеке его тоже нет.
- Debug-оверлей, метрики корректности, runtime-тюнинг.
## Чего нет и не будет
Врагов с ИИ, лута, прогрессии, UI, меню, звука, ECS, сторонних библиотек.
Мишени статичны и не атакуют — это шум для задачи.
Свет, туман войны и эффекты в исходных рамках тоже стояли в этом списке, но
появились вместе с хоррор-слоем. Правило, по которому их пустили: они живут
только в `render/`, `RenderGame` принимает `const Game&`, и на симуляцию повлиять
физически не могут. Ближний бой — единственное добавление, которое **меняет**
правила, поэтому он проверяется собственными критериями приёмки (M7.1M7.3).
## Технические рамки
| | |
|---|---|
| Язык | C++17, без зависимостей кроме raylib |
| Библиотека | raylib 6.0 (через CMake FetchContent, если нет системной) |
| Рендер сцены | **собственный софтверный растеризатор**, ручная запись пикселей |
| raylib используется для | окно, ввод, таймер, `UpdateTexture` + `DrawTexturePro`, debug-текст |
| Разрешение | внутреннее 480×270, окно 1440×810 (целочисленный ×3) |
| Симуляция | fixed timestep 60 Гц, детерминированная, рендер с интерполяцией |
| Потоки | один |
Ни одного raylib-примитива в отрисовке игровой сцены. Единственное исключение —
debug-оверлей поверх уже отмасштабированного кадра, он dev-only и снимается по `F1`.
## Куда дальше
- Собрать и запустить — [01-build-and-run.md](01-build-and-run.md).
- Понять устройство кода — [02-architecture.md](02-architecture.md).
- Понять **главное** (поведение отряда) — [05-squad-ai.md](05-squad-ai.md).

112
docs/01-build-and-run.md Normal file
View File

@ -0,0 +1,112 @@
# 01 — Сборка, запуск, управление
## Требования
- CMake 3.16+
- Компилятор с C++17. Проверено на MSVC 14.44 (Visual Studio 2022, x64).
- Интернет при **первой** конфигурации: raylib 6.0 качается через `FetchContent`.
Если в системе уже стоит raylib 6.x, `find_package(raylib 6)` возьмёт её и ничего не качает.
## Сборка
```bash
cmake -S . -B build # разово; тянет и собирает raylib (~1-2 мин)
cmake --build build --config Release --parallel
```
Бинарник: `build/bin/squad_proto.exe` (при любом генераторе, включая multi-config).
Debug-сборка (`--config Debug`) кладёт бинарник туда же и идёт в 60 fps, так что
проверять правки стоит именно в ней: MSVC включает проверки итераторов и ловит
выходы за границы, которых в Release не видно.
В VS Code: `F5` — конфигурация **Run (Release)** из `.vscode/launch.json`,
сборка идёт задачей `build release`. Есть также задачи `accept` и `headless 180s`.
> `Makefile` в корне остался от исходного шаблона raylib-vscode и **не используется**
> он ссылается на `C:/raylib/w64devkit`. Актуальная сборка только через CMake.
### Если CMake ругается на генератор
```
CMake Error: Error: generator : Visual Studio 17 2022
Does not match the generator used previously: Visual Studio 18 2026
```
Каталог `build/` привязан к конкретному генератору, и на машине с несколькими
Visual Studio его легко получить несогласованным: обычный `cmd.exe` и
Developer Command Prompt выбирают разные версии по умолчанию. Лечится сносом
каталога — он целиком производный, в нём нет ничего своего:
```bash
rmdir /s /q build # cmd
rm -rf build # bash
cmake -S . -B build
```
Чтобы зафиксировать версию явно, передай генератор при первой конфигурации:
```bash
cmake -S . -B build -G "Visual Studio 17 2022" -A x64
```
Дальше `cmake -S . -B build` без `-G` будет переиспользовать генератор из кэша.
`build/` намеренно в `.gitignore` — в репозитории его быть не должно.
## Управление
| Клавиша | Действие |
|---|---|
| `WASD` | движение диска (якоря отряда) в **экранных** осях |
| `Shift` (удерж.) | рывок (базовый шаг намеренно медленный) |
| `Space` | переключить режим ручного прицеливания |
| `←` / `→` | в ручном режиме — поворот вектора прицела |
| `Tab` | сменить назначенную цель (внутри конуса прицела) |
| `1` / `2` / `3` | строй: клин / линия / кольцо |
| `F1` | оверлей вкл/выкл |
| `F2` | линии огня |
| `F3` | кандидаты решателя и их оценки |
| `F4` | метрики |
| `F5` | выключить хоррор-слой (свет/туман/пост) |
| `F6` | показать диски обзора |
| `[` / `]` | выбрать параметр тюнинга |
| `-` / `=` | изменить выбранный параметр |
| `R` | сброс уровня |
| `Esc` | выход |
Стрельба **всегда автоматическая**, клавиши огня нет ни в одном режиме.
Ближний бой тоже: бойцы с клинком сами доходят до цели и бьют, пока она внутри
их круга ([11-melee.md](11-melee.md)).
Диск намеренно быстрее бойцов и держится к ним на поводке: команда читается
мгновенно, а средняя скорость похода всё равно определяется ногами отряда.
## Dev-режимы командной строки
Симуляция детерминирована и не зависит от рендера, поэтому её можно гонять без окна.
| Аргумент | Что делает |
|---|---|
| `--accept` | прогоняет сценарии критериев приёмки (спек 11) и печатает PASS/FAIL |
| `--headless <сек>` | прогон по маршруту без окна + метрики каждые 10 с |
| `--probe` | к `--headless`: подробности по каждому агенту на стоянках |
| `--no-solver` | отключить решатель огневых позиций (A/B сравнение) |
| `--no-laneavoid` | отключить расступание из чужих линий (A/B сравнение) |
| `--melee N` | сколько слотов строя держат клинок (по умолчанию 2, `0` = все стрелки) |
| `--set имя=значение` | подкрутить любой параметр из `TUNABLES` без пересборки |
| `--shot [кадров] [файл]` | снять скриншот через N кадров и выйти |
| `--clean` | стартовать без оверлея — чистый кадр для скриншотов |
Примеры:
```bash
squad_proto.exe --accept
squad_proto.exe --headless 300
squad_proto.exe --headless 300 --no-solver --no-laneavoid # каким прототип был бы без ядра
squad_proto.exe --headless 300 --melee 0 # чистый отряд стрелков
squad_proto.exe --headless 300 --set safetyMargin=0.25 --set laneReserve=2.0
squad_proto.exe --clean --shot 220 melee.png # кадр без отладки
```
Код прогонов — [src/debug/harness.cpp](../src/debug/harness.cpp), маршрут и сценарии описаны там же.
Что означают цифры в выводе — [06-debug-and-tuning.md](06-debug-and-tuning.md).

103
docs/02-architecture.md Normal file
View File

@ -0,0 +1,103 @@
# 02 — Архитектура
## Карта модулей
```
src/
main.cpp окно, ввод, главный цикл, fixed timestep. Игровой логики нет
game.h/.cpp склейка: карта + отряд + мишени + пули + метрики, порядок шага
tuning.h ВСЕ числа прототипа + таблица runtime-подкрутки
core/
math.h Vec2, отрезки, DistancePointToSegment, углы. Header-only
input.h InputState — ввод, влияющий на симуляцию
render/
view.h/.cpp ВСЁ render-only состояние + RenderGame(const Game&, ...)
framebuffer.h/.cpp софтверный буфер RGBA8, блит (в т.ч. со светом), аддитивные примитивы
sprites.h/.cpp процедурная генерация всех спрайтов при старте
iso.h проекция мир↔экран, камера, ключ сортировки по глубине
scene.h/.cpp проход по полу + общий список сущностей и стен с сортировкой
lighting.h/.cpp свет и туман войны (см. docs/10-atmosphere.md)
fx.h/.cpp трассеры, вспышки дула, искры попаданий
post.h/.cpp напряжение (Dread) и слитый пост-проход
noise.h единственное место render-only псевдослучайности
world/
tilemap.h/.cpp карта, генерация уровня, DDA-рейкаст, circle-vs-AABB
sim/
loadout.h класс оружия, параметры ствола и клинка, фазы удара
agent.h/.cpp агент, слои движения (seek/separation/targetPush/wallAvoid)
squad.h/.cpp якорь и поводок, строй, ADVANCING/HOLDING, сборка слоёв
target.h/.cpp мишени, респавн, отдача от удара и возврат на место
bullet.h/.cpp снаряды, коллизии с тайлами и капсулами
events.h кольцевой журнал боевых событий: единственный канал к эффектам
ai/
firing_solver.h/.cpp LaneClear, выбор цели, решатель огневых позиций
lane_registry.h/.cpp активные линии огня и секторы клинков, импульс расступания
melee.h/.cpp цель ближнего боя, подход вплотную, чистота сектора
debug/
overlay.h/.cpp оверлей F1F4 поверх кадра (единственное место с raylib-примитивами)
metrics.h/.cpp FRIENDLY_HITS, FIRE UPTIME, SETTLE TIME, журнал попаданий по своим
harness.h/.cpp прогоны --headless и --accept
```
Против структуры из спека добавлены: `game.h/.cpp`, `core/input.h`,
`render/scene.h/.cpp`, `debug/harness.h/.cpp`, хоррор-слой
(`render/view`, `lighting`, `fx`, `post`, `noise`, `sim/events.h`) и ближний бой
(`sim/loadout.h`, `ai/melee.h/.cpp`). Зачем — [08-decisions.md](08-decisions.md),
[10-atmosphere.md](10-atmosphere.md) и [11-melee.md](11-melee.md).
## Правила зависимостей
1. **`RenderGame` принимает `Game` по константной ссылке.** Поэтому свет, трассеры и
тряска камеры физически не могут изменить симуляцию — это проверяет компилятор,
а не внимательность. Всё render-only состояние живёт в `View`, которое
headless-прогоны не создают вообще.
2. **`ai/` не знает ничего** про рендер, изометрию и ввод. Только `core/`, `sim/`, `world/`.
3. **`sim/` и `world/` работают в декартовом мире** (top-down), тайл = 1.0 юнита.
Изометрия существует только в `render/` и в оверлее.
4. Ввод в экранных осях переводится в мировое направление ровно в одном месте —
`Game::Step` через `iso::ScreenDirToWorld`. Дальше по симуляции экранных осей нет.
5. `tuning.h` не зависит ни от чего; его включают все.
## Порядок одного шага симуляции
`Game::Step(dt)` — фиксированный порядок, важен именно он:
| # | Что | Почему здесь |
|---|---|---|
| 1 | пресет строя из ввода | до всего остального |
| 2 | мишени: респавн, отдача, возврат на место | состояние мира на начало шага |
| 3 | якорь + поводок + состояние отряда (`ADVANCING`/`HOLDING`) | движение зависит от состояния |
| 4 | **движение агентов** (слои 6.7) | реестр линий берётся с прошлого шага, задержка 1/60 с |
| 5 | ручное прицеливание, назначенная цель | до выбора целей |
| 6 | **оценка линий огня** и **выбор цели клинка** по новым позициям | целеуказание, `hasLane`, `melee.inReach` |
| 7 | пересборка реестра: линии огня + секторы клинков | для расступания и оценки кандидатов |
| 8 | решатель огневых позиций / подход клинка вплотную | решатель — только в `HOLDING` и только без линии |
| 9 | доворот ствола (у клинка на замахе ось зафиксирована) | визуальная часть |
| 10 | **атака**: огонь, затем взмах | позиции те же, что в п.6 |
| 11 | пули | коллизии со стенами и капсулами |
| 12 | метрики | снимок конца шага |
Ключевое: пункты 6 и 10 разделены только доворотом ствола, позиции между ними
не меняются. Поэтому отрезок, который проверил `LaneClear`, и отрезок, по которому
полетела пуля, — это буквально один и тот же отрезок. Для клинка то же самое
верно про сектор: проверенный в п.6/8 и рассечённый в п.10 — один и тот же.
## Детерминизм
- Фиксированный шаг 1/60, накопитель времени, максимум 5 шагов за кадр.
- Ни одного вызова RNG в симуляции. Уровень и расстановка мишеней захардкожены.
- Вырожденные случаи разрешаются по индексу слота, а не случайно
(например, расталкивание двух агентов, стоящих в одной точке).
- Камера обновляется в рендере по реальному времени кадра и на симуляцию не влияет.
Поэтому `--headless` воспроизводит один и тот же прогон и годится как тест.
## Рендер и симуляция
`RenderGame(const Game&, View&, ...)` не может изменить симуляцию — это гарантирует
подпись. Позиции агентов и пуль интерполируются `Lerp(prevPos, pos, alpha)`, камера
сглаживается по `frameDt` и округляется до целого пикселя, чтобы картинка не дрожала.
Единственный канал в обратную сторону — кольцевой журнал `sim/events.h`: симуляция
пишет в него боевые события, эффекты читают. Правила его не читают, в headless
передаётся `nullptr`. Подробности — [10-atmosphere.md](10-atmosphere.md).

102
docs/03-rendering.md Normal file
View File

@ -0,0 +1,102 @@
# 03 — Рендер: софтверный буфер, изометрия, спрайты
## Фреймбуфер
[src/render/framebuffer.h](../src/render/framebuffer.h) — `std::vector<uint32_t>` 480×270.
Формат — RGBA8, в памяти байты идут `R,G,B,A`, значит little-endian `uint32` это
`A<<24 | B<<16 | G<<8 | R`. Помощник `RGBA(r,g,b,a)` собирает цвет правильно.
Примитивы: `Clear`, `Put`, `Blit`, `BlitTinted`, `FillRect`, `Line` (Брезенхем),
`CircleOutline` (средняя точка), `CircleFilled`. Всё пишет пиксели руками.
Спрайты пишутся **альфа-тестом**: пиксель с `alpha == 0` не пишется, полупрозрачности
нет. Для эффектов есть **аддитивное** смешивание с насыщением (`AddSat`, `AddBeam`,
`AddGlowDisc`) и умножение (`ScaleColor8`, `BlitMul`), а для света — `BlitShaded`,
пропускающий цвет через световую рампу. Подробности — [10-atmosphere.md](10-atmosphere.md).
`Blit` обрезает спрайт по границам буфера ДО циклов и **выходит при пустом
диапазоне**: иначе адрес строки вычисляется за концом вектора. Цикл при этом не
выполняется, разыменования нет, но индекс уже невалиден — Debug-сборка ловит это
как `vector subscript out of range`, Release молча считает мусорный адрес.
Вывод на экран — `ScreenBlitter`: `UpdateTexture` + `DrawTexturePro` с
`TEXTURE_FILTER_POINT`. Это единственное место рендера, знающее про raylib;
`Texture2D` спрятан за `void*`, чтобы `raylib.h` не протёк в заголовок.
## Изометрия
[src/render/iso.h](../src/render/iso.h). Тайл 32×16:
```
sx = (wx - wy) * 16
sy = (wx + wy) * 8
```
Обратная проекция: `wx = sx/32 + sy/16`, `wy = sy/16 - sx/32`.
`ScreenDirToWorld` переводит направление ввода из экранных осей в мировые —
именно это делает `WASD` интуитивным. Больше изометрия нигде не используется.
**Камера** следует за интерполированным якорем экспоненциальным сглаживанием
(`1 - exp(-smooth*dt)`), смещение округляется до целых пикселей — иначе
при ×3 апскейле картинка дрожит.
## Сортировка по глубине
Ключ — `wx + wy`. Пол рисуется первым сплошным проходом и в сортировке не участвует.
Все стены и все сущности попадают в **один** список `Scene::items` и сортируются
`std::stable_sort` (стабильная — чтобы порядок был детерминирован при равных ключах).
Отсечение — по экранным координатам с запасом на высоту спрайта стены.
## Точка привязки спрайта
У `Sprite` есть поля `ax, ay` — точка внутри спрайта, которая совмещается с
экранной проекцией мировой позиции. Так блит не зависит от размера спрайта:
| Спрайт | Размер | Привязка | Смысл |
|---|---|---|---|
| пол | 32×16 | (16, 8) | центр ромба = центр тайла |
| стена | 32×32 | (16, 24) | низ блока садится на ромб пола |
| агент (стрелок) | 10×14 | (5, 12) | «ноги» на земле |
| агент (клинок) | 12×15 | (6, 13) | шире в плечах, класс читается силуэтом |
| мишень | 12×16 | (6, 14) | то же |
У пули спрайта нет: снаряд рисуется светящимся трассером, см. [10-atmosphere.md](10-atmosphere.md).
## Процедурные спрайты
[src/render/sprites.cpp](../src/render/sprites.cpp), файлов с ассетами нет.
- **Пол** — ромб 32×16, два оттенка тёмно-серого металла по шахматке `(x+y)&1`,
затемнённый рант по краю и редкие светлые крапины для фактуры.
- **Стена** — верхняя грань (тот же ромб) + вертикальные грани: для каждого столбца
ищется нижняя кромка ромба и вниз заливается на высоту тайла. Левая грань темнее
правой. Обводка по силуэту — пиксели, у которых есть прозрачный 4-сосед.
- **Агент** — капсула: пиксель внутри, если расстояние до вертикального отрезка
меньше радиуса; внешний пиксель кольца — обводка, слева-сверху блик.
5 различимых цветов, по одному на агента.
- **Агент с клинком** — та же капсула, крупнее, плюс стальная вертикаль с бликом
и гардой у правого края. Цвет уже занят номером агента, поэтому класс обязан
читаться формой. Сам взмах рисуется не спрайтом, а аддитивной дугой
([11-melee.md](11-melee.md)).
- **Мишень** — та же капсула, тускло-красная, 12×16. В темноте тело не видно —
виден только тлеющий красный «глаз», нарисованный аддитивно поверх сцены.
Порядок отрисовки кадра:
```
Clear(чёрный) → свет → пол (BlitShaded) → список (стены, мишени, агенты)
→ сортировка → блит → глаза мишеней → трассеры, вспышки и дуги клинков
(аддитивно) → ОДИН пост-проход → Present → оверлей (raylib, dev-only)
```
Три инварианта, нарушение любого ломает картинку молча:
1. альфа фреймбуфера всегда 255;
2. ступень 0 световой рампы совпадает с цветом `Clear` — только поэтому полностью
тёмные тайлы можно не рисовать;
3. стена никогда не темнее самого яркого соседа-пола — иначе сквозь пропущенный
тёмный блок проступит освещённый пол тайла (tx1, ty1), который рисуется
раньше по глубине.

80
docs/04-world.md Normal file
View File

@ -0,0 +1,80 @@
# 04 — Мир: карта, рейкаст, коллизии, мишени
## Тайлмап
[src/world/tilemap.h](../src/world/tilemap.h). Сетка 48×48, тайл `EMPTY` или `WALL`.
Тайл = 1.0 юнита, координаты сущностей — `float`.
**Вне карты считается стеной.** Это убирает граничные проверки из рейкаста и коллизий.
## Планировка уровня
Генерируется кодом, без RNG, полностью детерминированно (`Tilemap::Generate`).
```
y 0..1 сплошная рамка (2 тайла по всему периметру)
y 2..12 северная комната: кучка из 3 мишеней + колонны
y 13..15 стена-разделитель на всю ширину
проход A: x=10..11 проход B: x=30..31 (оба шириной 2 тайла)
y 16..45 центральная арена: кучка из 5 мишеней, колонны, огрызки стен, блок 2x2
x 12..14 западная стена-разделитель (y 20..44), проход: y=30..31
x 38..40 восточная стена-разделитель (y 18..40), проход: y=27..28
```
Отряд стартует в `(24, 26)` — открытая арена, кучка из 5 мишеней в радиусе оружия.
Колонны и огрызки стен раскиданы по всей карте намеренно: без них не проверить
ни LOS, ни обход. Три прохода шириной 2 тайла — это тест-кейс, где правило
«не стрелять сквозь своих» упирается в геометрию.
Северная кучка мишеней стоит **напротив прохода B** (`x=29.5/31.5`), так что из
коридора до неё есть чистая линия. Без этого проверить критерий «в узком проходе
отряд вытягивается в колонну, стреляет передний» было бы не на чем.
Кучка обязана быть **в пределах дальности от середины прохода** (`y≈14`), иначе
тест-кейс проверяет пустоту. При укорочении дальности до 7.2 её пришлось
подтянуть на два тайла к югу (`y` 5.58.5 → 7.510.5): ближняя мишень теперь в
~6.5 юнита. Меняешь `weaponRange` — пересчитывай и эти координаты.
## DDA-рейкаст
`Tilemap::RaycastBlocked(a, b, tHit)` — Amanatides & Woo, без аллокаций.
Возвращает, пересекает ли отрезок стену, и опционально параметр входа в стену `[0..1]`.
Используется в трёх местах:
- `LaneClear` — пункт «геометрия» (спек 6.3);
- пуля — поиск столкновения со стеной за шаг;
- оценка кандидатов решателя — там же, через `LaneClear`.
## Коллизии
`ResolveCircle(pos, r)` — circle vs AABB тайла: для каждой стены в охватывающем
прямоугольнике ищется ближайшая точка AABB и круг выталкивается по нормали.
Три итерации — из-за углов, где выталкивание одной стеной загоняет в другую.
Вырожденный случай (центр внутри тайла) выталкивается по ближайшей грани.
**Скольжение вдоль стен** делается в `IntegrateAgent`: после выталкивания из
скорости вычитается только нормальная составляющая, касательная остаётся.
Плюс к этому `WallAvoidForce` даёт касательную силу обхода — см.
[05-squad-ai.md](05-squad-ai.md), слой 4.
`CircleHitsWall(pos, r)` — быстрая проверка «в стене ли», для отбраковки кандидатов.
## Мишени
[src/sim/target.h](../src/sim/target.h). 12 штук, не атакуют и сами никуда не идут.
- Расстановка кучками: 5 (арена) + 3 (север) + 3 (запад) + 1 (восток).
- HP 500, урон пули 12 → 42 попадания. Урон клинка 130 → 4 удара.
- Умерла → респавн через 3 с на том же месте. Прототип работает бесконечно.
Мёртвая мишень не рисуется, не выбирается целью и не участвует в оценке покрытия.
**Отдача.** Удар в ближнем бою отбрасывает мишень (`meleeKnockback`), импульс
гасится (`targetKnockDamp`), и мишень возвращается на своё место `home`
(`targetReturnRate`). Без отдачи попадание в упор визуально не отличается от
промаха. Возврат обязателен: расстановка захардкожена, и уползание мишеней
поломало бы все замеры разом — респавн дополнительно ставит `pos = home`.
**Тело.** Мишень расталкивает агентов (`TargetPushForce`, `wTargetPush`): до
ближнего боя агент мог стоять внутри мишени, и удар не читался вообще.

254
docs/05-squad-ai.md Normal file
View File

@ -0,0 +1,254 @@
# 05 — Ядро: поведение отряда
Главный файл проекта. Всё здесь — чистые функции над состоянием мира,
без рендера и изометрии: [src/ai/firing_solver.cpp](../src/ai/firing_solver.cpp),
[src/ai/lane_registry.cpp](../src/ai/lane_registry.cpp), [src/sim/squad.cpp](../src/sim/squad.cpp).
Этот файл описывает поведение **стрелка**. Боец ближнего боя живёт по тем же
правилам в угловой форме — [11-melee.md](11-melee.md).
## Состояния отряда
- `ADVANCING` — есть ввод движения.
- `HOLDING` — ввода нет дольше `holdDelay` (0.15 с).
Состояние общее для отряда, копируется в каждого агента. При смене состояния
сбрасываются `commitTimer`, а при входе в `HOLDING` каждому агенту ставится
`postPos = его текущая позиция`: отряд встал там, где встал, дальше его
расставляет решатель.
## 1. Проверка линии огня — `LaneClearFrom`
Сердце всей механики. Порядок проверок ровно как в спеке 6.3:
```
muzzle = from + normalize(target - from) * muzzleOffset // 0.3 юнита
1. свои: для каждого союзника (кроме стрелка)
если DistancePointToSegment(ally.pos, muzzle, target)
< ally.bodyRadius + laneHalfWidth + safetyMargin -> нет линии
2. геометрия: если DDA-рейкаст muzzle->target упёрся в стену -> нет линии
3. дистанция: если |muzzle - target| > weaponRange -> нет линии
```
Порог по умолчанию: `0.28 + 0.10 + 0.15 = 0.53` юнита.
Функция принимает **произвольную точку** `from`, а не только позицию агента —
поэтому ей же оценивается «а если я встану вот сюда» в решателе.
Дуло считается в сторону цели, а не по текущему `facing`. Это важно: пуля
вылетает из той же точки и в том же направлении, что проверялось.
## 2. Выбор цели
`EvaluateAgentLane` — один проход на агента за шаг:
- **Авто-режим**: ближайшая живая мишень в радиусе, до которой линия чиста.
- **Ручной режим** (спек 6.8): сначала назначенная цель. Если до неё линии нет:
- в `HOLDING` агент молчит и перестраивается решателем (цель решателя = назначенная);
- в `ADVANCING` стреляет по вторичным целям, только если `freeFireSecondary` (по умолчанию да).
Побочно заполняются флаги для оверлея: `targetBlockedByAlly`, `targetBlockedByWall`,
и `laneIntent` — «цель есть, мешает только свой» (см. пункт 4).
## 3. Решатель огневых позиций (`HOLDING`)
`SolveFiringPosition`. Запускается **не каждый кадр**: у каждого агента свой
`solveTimer` с интервалом 0.25 с, фазы разведены при старте так, что за кадр
решает максимум один агент.
Условия запуска: состояние `HOLDING`, `commitTimer == 0`, и у агента **нет**
линии к приоритетной цели.
**Кандидаты**: текущая позиция (индекс 0, для сравнения) + 3 кольца
радиусами 0.8 / 1.6 / 2.4 × 12 направлений = 37 точек.
**Отбраковка**: вне карты, в стене, ближе 0.55 юнита к другому агенту.
Текущая позиция не отбраковывается никогда — она база сравнения.
**Оценка** (`ScoreCandidate`, все веса в `tuning.h`):
```
+100 если из кандидата чиста линия до основной цели
+25 * (простреливаемых целей / всего живых целей)
-35 * (число союзников, чью текущую линию перекрывает кандидат)
- 9 * расстояние(кандидат, текущая позиция)
-24 * max(0, расстояние(кандидат, якорь) - cohesionRadius 2.1)
-60 если ближайшая цель ближе minEngageDist 1.6
```
**Жёсткая граница**: кандидат дальше `cohesionRadius + postLeash` (2.1 + 1.0) от
якоря отбраковывается совсем. Одной цены не хватает — линия огня стоит 100 очков,
и за неё решатель готов уйти сколько угодно далеко. Замер: без жёсткой границы
стрелок отходил от диска на 3.65 юнита, с ней — на 2.81, а `FIRE UPTIME` на
маршруте вырос, а не упал (отряд плотнее — линий больше).
**Коммит с гистерезисом** — без него агент дёргается:
```
если bestScore > currentScore + 15:
postPos = лучший кандидат
commitTimer = 0.6 // это время не перерешивать
```
Пока `commitTimer > 0`, агент просто идёт к `postPos`.
Если целей в досягаемости нет вообще, решателю не над чем работать — тогда
`postPos` = слот строя, чтобы отставший агент возвращался к отряду
(случай, которого нет в спеке, см. [08-decisions.md](08-decisions.md)).
## 4. Резервирование линий и расступание
[src/ai/lane_registry.cpp](../src/ai/lane_registry.cpp). Каждый шаг собирается список
активных линий — отрезков «дуло → цель». В реестр попадают:
- агенты с чистой линией (реально стреляющие);
- **агенты, чью линию перекрыл только свой** (`laneIntent`).
Второй пункт — важное уточнение против буквального текста спека. Если
регистрировать только реально стреляющих, то стоит кому-то влезть в линию —
у стрелка линия пропадает, отрезок исчезает из реестра, и **перекрывшего ничто
не выталкивает**. Сектор обстрела не освобождается никогда. Подробно и с цифрами —
[08-decisions.md](08-decisions.md).
Импульс: агент, попавший в чужую линию, получает силу **перпендикулярно** линии
в сторону ближайшего края; сила растёт при приближении к оси
(`(1 - d/threshold) * laneAvoidStrength`, с насыщением до 1).
Порог резерва шире, чем порог «тело в линии» из 6.3, в `laneReserveScale` раз
(по умолчанию 1.8). Причина — там же в 08-decisions: резерв должен отжимать
агента **до** того, как он влез под уже летящую пулю.
Работает в обоих состояниях отряда. Это и даёт главное ощущение: отряд
«расплывается» сам, без единой явной команды.
## 5. Движение агента — слои
`Squad::UpdateMovement`, порядок из спека 6.7:
| # | Слой | Вес | Файл |
|---|---|---|---|
| 1 | `seek` к цели (слот строя или `postPos`), с торможением у цели | `wSeek` 1.0 | `agent.cpp` |
| 2 | `separation` от союзников, радиус 0.7 | `wSeparation` 1.0 | `agent.cpp` |
| 3 | `laneAvoidance` — расступание из чужих линий и секторов | `wLaneAvoid` **2.4** | `lane_registry.cpp` |
| 3 | `targetPush` — мишень тоже тело, а не пустое место | `wTargetPush` 1.0 | `agent.cpp` |
| 4 | `wallAvoidance` + касательное скольжение | `wWallAvoid` 1.2 | `agent.cpp` |
| 5 | клампинг скорости, интеграция, разрешение коллизий | — | `agent.cpp` |
Вес расступания намеренно выше веса separation — так требует спек.
Слой 3 добавлен вместе с ближним боем: без него боец стоял **внутри** того,
кого рубит. Стрелков он тоже касается — они перестали проходить сквозь мишени.
Боец ближнего боя в активном окне удара весь этот стек пропускает: он едет по
оси удара со своей скоростью и своим ускорением, остаются только стены.
Слой 4 не только отталкивает от стены, но и добавляет **касательную** составляющую
в сторону цели, когда стена стоит между агентом и целью. Без этого seek и
отталкивание уравновешиваются, и агент залипает в «тени» препятствия — это
реально ловилось в прогонах (агент оставался в комнате, когда отряд ушёл).
Скорость 1.75 юнита/с, 2.7 на `Shift`. Скорость подтягивается к желаемой с
ограничением `steerAccel`, чтобы резкие развороты не телепортировали агента.
## 5a. Диск и поводок
Якорь («диск», за которым идёт камера) движется **быстрее** бойцов: 3.3 против
1.75, на `Shift` 4.8 против 2.7. Это отвечает за отзывчивость: старт и разворот
читаются мгновенно, а не через полсекунды инерции пятерых.
Само по себе это ломало бы отряд — диск просто уезжал бы вперёд, слоты строя за
ним, и агенты вечно бежали бы в хвосте. Поэтому есть **поводок**, и он
**упругий**, а не жёсткий:
| Параметр | Смысл |
|---|---|
| `anchorLeash` (2.4) | мягкая граница: до неё диск идёт свободно |
| `anchorLeashMax` (3.6) | жёсткий предел: страховка, дальше не пускает вообще |
| `leashDrag` (0.75) | между границами диск тяжелеет; на упоре остаётся 25 % скорости |
Жёсткий поводок здесь не годился принципиально: он мгновенно упирается,
дистанция намертво встаёт в одно значение, и по ней уже нельзя отличить
«подправил направление» от «тяну отряд вперёд изо всех сил». Упругий поводок
делает дистанцию непрерывной величиной — а из неё считается **натяжение**
(`urgency`), которое разгоняет отряд, растягивает строй и на верхней трети шкалы
(`sprintAt`) прекращает огонь вовсе.
Итог: «быстро» — это отзывчивость на старте и на развороте, а средняя скорость
похода всё равно определяется ногами отряда. Порядок важен: сдвиг → сопротивление
→ жёсткий предел → `ResolveCircle` по стенам.
### Равновесие поводка, или почему отряд идёт с боем
Диск быстрее отряда, поэтому при зажатом направлении дистанция растёт, пока
сопротивление поводка не сравняет скорости. Это равновесие — рабочий режим игры,
и считать его надо явно:
```
pull = (dist - anchorLeash*urgeStartFrac) / (anchorLeashMax - anchorLeash*urgeStartFrac)
speed = lerp(moveSpeed, moveSpeedFast, urgency) отряд
anchor = anchorSpeed * max(0.08, 1 - leashDrag*over) диск
```
На текущих числах равновесие садится на `dist ≈ 3.4`, то есть `urgency ≈ 0.55`
и скорость отряда ≈ 2.8 юнита/с**ниже порога `sprintAt` (0.85)**. Значит,
удерживая направление, игрок ведёт отряд быстрым шагом, и отряд при этом
**продолжает работать по целям**: и стрелки, и клинки.
Спринт остаётся отдельным решением: либо `Shift` (прямой приказ), либо
ситуация, когда курсор реально уехал. И только в спринте огонь прекращается.
Раньше порог стоял на 0.62, равновесие было выше него, и любое удержание клавиши
означало вечный бег без стрельбы. Это и ощущалось как «милишники не работают».
## 6. Строй
Три пресета, локальные координаты (`x` = вперёд, `y` = вправо), множатся на
`formationSpacing` и поворачиваются по направлению движения отряда:
- **клин**: вершина впереди, двое по бокам, двое сзади шире;
- **линия**: пятеро в ряд поперёк движения;
- **кольцо**: пятеро по окружности радиуса 1.5 × spacing.
В `HOLDING` слоты не используются — агент идёт к `postPos` от решателя.
Первые `meleeCount` слотов держат клинок (по умолчанию 2). В клине это остриё и
левое крыло — те, кто и так упирается в противника первым. Боец ближнего боя
идёт к `postPos` в **обоих** состояниях отряда: иначе клинок был бы полезен
только на стоянке, а нужен он как раз в движении.
## 7. Огонь
`Game::FireWeapons`. Выстрел происходит, только если выполнено **всё**:
1. есть цель и чистая линия (`hasLane`);
2. истёк `cooldown` (0.35 с);
3. ствол доведён до цели (±5°) — иначе выстрел выглядел бы враньём;
4. `LaneClearFrom` подтверждает линию по позициям этого шага (повторно, «никаких исключений»);
5. `AllyInterceptsShot` — за время полёта пули свой не окажется на её пути.
Пункт 5 — добавление к спеку, вынужденное тем, что 6.3 проверяет линию в момент
выстрела, а 6.9 делает пулю настоящим снарядом с временем полёта до 0.4 с.
Обоснование и замеры — [08-decisions.md](08-decisions.md).
## 8. Пули
`friendlyFire = true` всегда: пуля сталкивается со стенами, мишенями и **со всеми
капсулами, включая союзников** (кроме самого стрелка). За шаг ищется самое раннее
столкновение вдоль пройденного отрезка, чтобы стена перед союзником срабатывала первой.
Пуля живёт ровно столько, чтобы пройти **проверенный** отрезок (дуло → цель, плюс
радиус мишени). Дальше она не летит: за целью пространство никто не проверял, и
если цель успела умереть от чужой пули, снаряд ушёл бы в непроверенную зону.
Счётчик попаданий по своим и журнал событий (время, стрелок, жертва, место,
состояние жертвы) — в [06-debug-and-tuning.md](06-debug-and-tuning.md).
## 9. Ручное прицеливание
`Space` включает режим, `←`/`→` крутят вектор прицела от якоря, `Tab` циклит
цели в конусе ±20°, отсортированные по дальности от якоря. Все агенты
приоритезируют назначенную цель.
Правила 18 при этом **не меняются вообще**. Ручной режим никогда не позволяет
стрелять сквозь своих — тот же `LaneClearFrom` на том же спуске.

150
docs/06-debug-and-tuning.md Normal file
View File

@ -0,0 +1,150 @@
# 06 — Оверлей, метрики, тюнинг
## Оверлей
[src/debug/overlay.cpp](../src/debug/overlay.cpp). Рисуется примитивами raylib **поверх**
уже отмасштабированного кадра. Это единственное исключение из «вся сцена софтверная»,
и оно dev-only: `F1` его убирает.
| Клавиша | Что показывает |
|---|---|
| `F1` | оверлей целиком |
| `F2` | линии огня |
| `F3` | кандидаты последнего прогона решателя |
| `F4` | метрики |
| `F5` | выключить хоррор-слой целиком (свет, туман, пост) |
| `F6` | показать диски обзора |
Всегда (при включённом `F1`): якорь отряда, **два** круга когезии (синий —
стрелков, оранжевый — клинков), слоты строя, подпись над каждым агентом
`индекс, класс (R/M), состояние (A/H), id цели` и у клинка фаза удара
(`wind` / `HIT` / `rec`), выбранная огневая позиция `postPos` кружком.
**Сектор клинка**: кольцо досягаемости и две границы дуги, цвет по фазе —
жёлто-оранжевый на замахе, белый на ударе, серый на восстановлении. В покое цвет
говорит, почему не бьём: жёлтый — свой в дуге, синий — просто ждёт.
**Цвета линий огня (`F2`)**:
| Цвет | Значение |
|---|---|
| зелёный | линия чиста, агент стреляет |
| жёлтый | цель есть, линию перекрыл свой |
| красный | линию перекрыла стена |
**Кандидаты (`F3`)**: точками, цвет по оценке (синий низкая → белый высокая),
отбракованные — тусклым красным, выбранный обведён белым кругом.
Показывается последний прогон решателя, поэтому у агентов, у которых линия есть,
там пусто — решатель для них не запускался, так и задумано.
В ручном режиме дополнительно рисуется вектор прицела, границы конуса ±20°
и обводка назначенной цели.
## Метрики (`F4`)
[src/debug/metrics.h](../src/debug/metrics.h).
| Метрика | Как считается | Смысл |
|---|---|---|
| **FRIENDLY_HITS** | накопительный счётчик попаданий пуль по своим | главная метрика корректности, должна быть 0 |
| **FIRE UPTIME** | доля шагов с валидной линией за окно 10 с; среднее — **только по стрелкам** | насколько отряд «в деле» |
| **MELEE reach** | доля шагов с целью в зоне удара, среднее по клинкам | то же самое для ближнего боя |
| `swings / hits / aborted` | взмахов начато, попаданий лезвием, взмахов оборвано своим в дуге | `aborted` — видимая цена скученности, не баг |
| **SETTLE TIME** | от перехода в `HOLDING` до момента, когда ≥4 агента «в бою» | как быстро расходится веером |
| fps, `sim ms/frame` | время симуляции за кадр | бюджет 16.6 мс |
«В бою» для стрелка — валидная линия, для клинка — выбранная цель. Смешивать
доли времени двух разных занятий в одно среднее бессмысленно, поэтому
`FIRE UPTIME` и `MELEE reach` считаются раздельно: у клинка линии огня не бывает
по определению, и его ноль занижал бы метрику стрельбы.
Реализация `FIRE UPTIME` — кольцевой буфер на 600 сэмплов на агента с
инкрементальными счётчиками, пересчёт O(1) за шаг.
**Журнал попаданий по своим**: каждое попадание пишется в `Metrics::hitLog`
(время, стрелок, жертва, координаты, состояние жертвы). Без него `FRIENDLY_HITS != 0`
нечем объяснить — именно журнал показал, что жертва всегда была `ADVANCING`,
и вывел на настоящую причину (см. [08-decisions.md](08-decisions.md)).
### Как читать цифры
- `FIRE UPTIME` считает «нет живой цели в радиусе» как простой. Поэтому на
переходах между комнатами и после зачистки кучки метрика честно падает — это не
дефект ИИ. Критерий приёмки 11.8 относится к **стоячему бою рядом с мишенями**.
- `SETTLE TIME` по той же причине растёт, если отряд остановился там, где стрелять
не по чему. Сценарий `--accept` меряет его в правильной обстановке.
## Runtime-тюнинг
Все числа живут в [src/tuning.h](../src/tuning.h) в структуре `Tuning`.
Магических констант по коду нет — если нужно новое число, оно добавляется туда.
Часть параметров вынесена в таблицу `TUNABLES` и крутится на ходу:
- `[` / `]` — выбрать параметр, текущее имя и значение видно в оверлее;
- `-` / `=` — изменить с шагом параметра (с зажатием работает автоповтор).
Те же параметры можно задать из командной строки без пересборки:
```bash
squad_proto.exe --headless 300 --set safetyMargin=0.25 --set laneReserve=2.0
```
Список параметров смотри прямо в `TUNABLES` — там имя, поле, шаг и границы.
Самые содержательные для ощущения:
| Параметр | На что влияет |
|---|---|
| `safetyMargin` | запас к «телу в линии»; шире → реже стреляют, но безопаснее |
| `laneReserve` | во сколько раз резерв линии шире порога блокировки; отвечает за «текучесть» |
| `wLaneAvoid` | насколько нагло агент выдавливается из чужого сектора |
| `hysteresis` | насколько лучше должна быть новая позиция, чтобы агент переехал |
| `commitTime` | сколько держится решение; меньше → живее и дёрганее |
| `sDistCost` / `sCohesionCost` | готовность отбегать далеко ради выстрела |
| `postLeash` | жёсткая граница огневой позиции от диска; ниже — отряд плотнее |
| `anchorSpeed` / `anchorLeash` | отзывчивость диска и насколько он может оторваться от отряда |
Ближний бой (подробности — [11-melee.md](11-melee.md)):
| Параметр | На что влияет |
|---|---|
| `meleeReach` / `meleeArc` | размер сектора удара; шире → чаще задевает нескольких, но и своих |
| `meleeWindup` / `meleeStrike` / `meleeRecover` | ритм удара: читаемость замаха против скорострельности |
| `meleeInterval` / `meleeDamage` | DPS клинка |
| `meleeLunge` / `meleeKnock` | вес удара: рывок бойца и отдача мишени |
| `meleeCohesion` | СВОЙ, больший круг клинка вокруг диска |
| `meleeAllyMargin` / `meleePredict` | строгость дисциплины сектора; выше → меньше оборванных взмахов и меньше взмахов вообще |
| `meleeReserve` | насколько широко клинок отжимает своих со своей дуги |
| `meleeShake` | отдача камеры на попадание лезвием |
Хоррор-часть (подробности — [10-atmosphere.md](10-atmosphere.md)):
| Параметр | На что влияет |
|---|---|
| `sightRadius` / `sightCone` | как далеко и как широко видит отряд |
| `sightPersonal` | круговой ореол вокруг агента, спасает от клаустрофобии |
| `fogMemory` | насколько тускло помнится уже увиденное |
| `fogReveal` | при каком свете показывается тело мишени |
| `lightGamma` / `lightGain` | форма затухания света; **перепекают таблицу рампы** |
| `flashPower` | насколько вспышка выстрела освещает окружение |
| `fxIntensity` / `tracerLength` | яркость и длина трассеров |
| `grainTense` / `vigDepthTense` | зерно и виньетка на пике напряжения |
| `dreadRadius` | с какой дистанции мишень начинает пугать |
| `shakePerShot` | отдача камеры |
`F5` — аварийный тумблер: мгновенно возвращает прежний кадр без света и тумана.
Полезен, когда непонятно, баг это или просто темно.
## Dev-тумблеры ядра
`--no-solver` и `--no-laneavoid` выключают решатель и расступание. Это A/B-проверка
ценности ядра, цифры за 60 с на одном маршруте:
| Конфигурация | FRIENDLY_HITS | FIRE UPTIME |
|---|---|---|
| ядро выключено | 1 | 13.6 % |
| ядро включено | 0 | 88 % (на стоянке у мишеней) |
`sim`/`render` в оверлее показывают время шага симуляции и время кадра рендера
раздельно: 2 мкс против 1.43.5 мс при бюджете 16.6 мс.

54
docs/07-milestones.md Normal file
View File

@ -0,0 +1,54 @@
# 07 — Журнал вех
Порядок из раздела 10 спека: после каждой вехи сборка и запуск, потом следующая.
| Веха | Что сделано | Чем проверено |
|---|---|---|
| **M0** | CMake + raylib через FetchContent, фреймбуфер 480×270, ×3 апскейл, fixed timestep, изометрическая сетка | сборка MSVC x64, окно открывается, текстура 480×270 RGBA8 создаётся |
| **M1** | тайлмап 48×48, генерация уровня, процедурные спрайты пола и стен, камера за якорем, сортировка по глубине | скриншот: ромбы пола шахматкой, кубы стен перекрывают друг друга правильно |
| **M2** | 5 агентов, движение якоря в экранных осях, три строя, separation, коллизии со стенами | скриншот: пятеро в клину, цвета различимы, стены не проходятся |
| **M3** | мишени с респавном, DDA-рейкаст, `LaneClear`, авто-огонь, пули-снаряды, дружественный урон, `FRIENDLY_HITS` | `--headless 60 --no-solver --no-laneavoid`: 1 попадание по своим, uptime 13.6 % — то есть проблема, которую должно решать ядро, воспроизводится |
| **M4** | решатель огневых позиций, гистерезис и коммит, реестр линий и расступание | то же 60 с с включённым ядром: 0 попаданий, uptime 88 % на стоянке у мишеней |
| **M5** | ручное прицеливание, конус ±20°, назначенная цель, `Tab` | сценарий `11.7` в `--accept`: 0 попаданий по своим при активной назначенной цели |
| **M6** | оверлей F1F4, метрики, журнал попаданий по своим, runtime-тюнинг `[ ] - =`, `--set` | скриншот боевого кадра: зелёные линии у всех пятерых, FRIENDLY_HITS 0, uptime 99 % |
| **M7** | ближний бой: классы оружия, сектор удара, три фазы, свой больший круг, дуга; отзывчивый диск на поводке | `--accept`: M7.1M7.3 PASS, 81 взмах / 81 попадание / 23 убийства за 30 с, FRIENDLY_HITS 0; все критерии спека 11 по-прежнему PASS |
## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
которые на глаз не видны, нашлись именно так.
1. **Отставшие агенты.** `--probe` показал агента на `y=12`, когда отряд стоял на
`y=26`. Причина — залипание в «тени» стены (локальный минимум seek против
отталкивания) плюс отсутствие правила «идти к отряду, когда стрелять не по чему».
Починено касательным обходом стен и возвратом в слот строя (A5, A6).
2. **Резерв линии, который исчезает, когда нужен.** Линии регистрировались только у
реально стреляющих. Стоит кому-то влезть в линию — стрелок теряет линию, отрезок
пропадает из реестра, и перекрывшего ничто не выталкивает (A3).
3. **Пуля, летящая за цель.** Последнее упорное попадание по своим
(`t=94.49`, воспроизводилось при любых настройках) — снаряд проходил место
умершей мишени и находил своего за ней (A1).
4. **Выход за границы в блиттере спрайтов.** `Framebuffer::Blit` вычислял адрес
строки спрайта до внутреннего цикла. Если спрайт срезан левым краем экрана
больше, чем на свою ширину (`sx0 >= s.w`), индекс уезжал за конец `px`:
цикл пустой, разыменования нет, но индекс уже невалиден. Release это молча
глотал, Debug валился с `vector subscript out of range`. Починено ранним
выходом при пустом диапазоне обрезки.
Вывод на будущее: **проверять в Debug-сборке**. В Release такие ошибки
не проявляются — там нет проверок итераторов MSVC.
5. **Критерий 11.6 мерил не то, что означал.** Тест требовал, чтобы в коридоре
огонь вёл передний, и считал ВСЕ выстрелы. Диагностика показала, что
«нарушения» — это хвостовой агент, стреляющий **назад**, в оставленную позади
комнату: к правилу «не стрелять сквозь своего» это отношения не имеет, впереди
по такой линии никого нет по построению. Теперь считаются только выстрелы по
ходу движения, и результат стал строже: 5/0 вместо 12/4.
6. **Двое с клинками на одной мишени.** Взмахи обрывались друг об друга, урона
почти не было. Починено ценой цели: 37 взмахов / 31 попадание → 74 / 75.
Подробности и замеры по каждому — [08-decisions.md](08-decisions.md).
## Текущий статус
Все измеримые критерии раздела 11 проходят: `squad_proto.exe --accept`
«все проверяемые критерии PASS». Цифры — [09-acceptance.md](09-acceptance.md).

327
docs/08-decisions.md Normal file
View File

@ -0,0 +1,327 @@
# 08 — Решения и отклонения от спека
Спек в разделе 12 требует: раздел 6 реализуется как написано, а если считаешь,
что где-то ошибка — сначала скажи, потом делай. Здесь перечислено **всё**, что
отличается от буквы спека, с причиной и замерами.
Отклонения разделены на две группы: содержательные (меняют поведение) и
структурные (не меняют).
---
## A. Содержательные
### A1. Пуля не летит дальше проверенного отрезка
**Спек**: 6.9 — снаряд со скоростью 22 юнита/с.
**Сделано**: время жизни пули = длина отрезка «дуло → цель» / скорость,
а не `дальность оружия / скорость`.
**Почему**. `LaneClear` проверяет отрезок до цели. Если цель успевает умереть от
чужой пули, снаряд летит **дальше** — в пространство, которое никто не проверял.
Ровно так возникало последнее упорное попадание по своим: агент стоял внутри
кучки мишеней, пуля прошла место умершей мишени насквозь и нашла его.
**Замер**: до фикса — 1 попадание за 300 с, стабильно воспроизводимое
(`t=94.49, #1 -> #2 @ (30.6,21.7)`); после — 0 за 300 с.
### A2. Спуск учитывает время полёта пули (`AllyInterceptsShot`)
**Спек**: 6.3 проверяет линию в момент выстрела.
**Сделано**: дополнительно проверяется сближение пули и союзника как двух
движущихся точек на всём времени полёта, с радиусом безопасности, растущим со
временем (`shotPredictSlack` — запас на то, что союзник может и повернуть).
**Почему**. 6.3 даёт мгновенный снимок, а 6.9 делает пулю реальным снарядом:
до 0.4 с полёта, за которые союзник проходит больше метра. Союзник, честно
чистый в момент нажатия, спокойно входит в коридор уже после выстрела.
Реактивное расступание (6.6) этот случай не закрывает по определению — оно
срабатывает, только когда тело уже в линии.
Само правило 6.3 при этом **не ослаблено**: это дополнительное условие спуска,
а не замена. `LaneClear` продолжает использоваться и в целеуказании, и в оценке
кандидатов, и в реестре линий — в том виде, как написано в спеке.
**Замер**: журнал попаданий показывал, что жертва **всегда** была `ADVANCING`
то есть именно вбегала под летящую пулю.
### A3. В реестр линий попадают линии, перекрытые своим
**Спек**: 6.6 — «список активных линий огня — сегментов от **стреляющих** агентов».
**Сделано**: линия регистрируется и тогда, когда у агента есть цель в радиусе,
стена не мешает, а мешает **только свой** (`Agent::laneIntent`).
**Почему**. При буквальном прочтении механика самоотменяется: стоит кому-то
влезть в линию — стрелок теряет линию, перестаёт быть «стреляющим», отрезок
исчезает из реестра, и перекрывшего **ничто не выталкивает**. Сектор обстрела не
освобождается никогда, а обещание 6.6 («отряд сам расплывается, освобождая
сектора обстрела») и критерий 11.4 становятся недостижимыми.
**Замер**: `FIRE UPTIME` на маршруте вырос с 33 % до 49 %, худший uptime на
стоянке — с 38 % до 51 %.
### A4. Резерв линии шире порога блокировки (`laneReserveScale = 1.8`)
**Спек**: 6.6 — импульс, если `dist < bodyRadius + laneHalfWidth + SAFETY_MARGIN`.
**Сделано**: импульс расступания начинается на `laneReserveScale` шире этого
порога. Сам порог блокировки в 6.3 не тронут.
**Почему**. Раздел называется «**Резервирование** линий». Если отжимать агента
ровно с той границы, за которой он уже блокирует выстрел, — это не резерв, а
реакция постфактум: агент успевает войти в сектор, где летит пуля.
**Замер** (300 с, с A1 и A2):
| `laneReserve` | FRIENDLY_HITS | худший uptime на стоянке |
|---|---|---|
| 1.0 (буква спека) | 1 | 42.5 % |
| 1.4 | 0 | 50.0 % |
| **1.8** (принято) | 0 | 50.4 % |
### A5. Касательная составляющая обхода стен (`wallSlide`)
**Спек**: 6.7, слой 4 — «wallAvoidance + скольжение вдоль стен при коллизии».
**Сделано**: помимо скольжения при коллизии, `WallAvoidForce` добавляет
касательную силу в сторону цели, когда стена стоит между агентом и целью.
**Почему**. Чисто отталкивающее избегание даёт классический локальный минимум:
seek тянет вперёд, отталкивание держит — агент залипает в «тени» препятствия.
В прогонах это ловилось буквально: агент оставался в северной комнате
(`y=12`), когда отряд уже стоял на арене (`y=26`).
### A6. Агент без целей в радиусе возвращается в строй
**Спек**: случай не описан. 6.5 запускает решатель, когда линии нет, но не
говорит, что делать, если стрелять вообще не по чему.
**Сделано**: если решатель не нашёл ни одной цели в пределах
`weaponRange + ring3`, `postPos` = слот строя.
**Почему**. Иначе отставший агент стоит на месте вечно: в `HOLDING` он идёт
только к `postPos`, а `postPos` равен его же позиции.
### A7. Дуло считается в сторону цели, а не по `facing`
**Спек**: 6.3 — «`muzzle()` — точка в 0.3 юнита от центра агента по `facing`».
**Сделано**: для проверки линии и для спавна пули дуло считается по направлению
на цель; выстрел разрешён только когда ствол доведён до цели с точностью ±5°
(`aimToleranceDeg`).
**Почему**. `facing` доворачивается плавно и на шаг отстаёт от направления на
цель. Если проверять линию по одному направлению, а стрелять по другому, пуля
летит не по проверенному отрезку — то есть гарантия «не сквозь своих» перестаёт
быть гарантией. Сейчас проверенный отрезок и траектория пули — буквально один и
тот же отрезок.
### A8. Пуля не бьёт собственного стрелка
**Спек**: 6.9 — «коллизия со **всеми** капсулами, включая союзников».
**Сделано**: стрелок исключён из коллизий своей пули.
**Почему**. Дуло вынесено на 0.3 юнита при радиусе тела 0.28 — пуля стартует
почти вплотную к своему же телу. Самопопадание было бы вырожденным шумом в
`FRIENDLY_HITS`, главной метрике корректности. Все **остальные** капсулы,
включая союзников, проверяются как написано.
### A9. Северная кучка мишеней стоит напротив прохода
**Спек**: 4 — 12 мишеней кучками, конкретные места на усмотрение реализации.
**Сделано**: кучка из 3 мишеней в северной комнате поставлена напротив прохода
`x=30..31`, так что из коридора до неё есть чистая линия.
**Почему**. Спек называет узкий проход важным тест-кейсом, а критерий 11.6
требует, чтобы в проходе отряд вытягивался в колонну и стрелял только передний.
Без цели, простреливаемой сквозь проход, проверять нечего.
### A10. Вторичные цели в `HOLDING` при ручном прицеливании
**Спек**: 6.8 — флаг `freeFireSecondary` описан только для `ADVANCING`.
**Сделано**: буквально — в `HOLDING` агент без линии до назначенной цели молчит
и перестраивается решателем, по вторичным целям не стреляет.
**Почему**. Иначе агент нашёл бы вторичную цель, получил бы `hasLane`, и решатель
для него никогда бы не запустился — то есть требование «в `HOLDING`
перестраивается через решатель» не выполнялось бы.
### A11. Туман войны и освещение вопреки разделу 12
**Спек**: 12 — «не добавлять освещение, туман войны».
**Сделано**: добавлено и то, и другое.
**Почему**. Заказчик изменил требования: прототип разворачивается в хоррор, где
ограниченный обзор и темнота — не украшение, а основа подачи. Правила стрельбы
(раздел 6) при этом не тронуты ни одной строкой, критерии приёмки остались
зелёными. Устройство слоя — [10-atmosphere.md](10-atmosphere.md).
### A12. Конус зрения не влияет на ИИ
**Сделано**: у агента есть конус обзора для КАРТИНКИ, но выбор целей и стрельба
по-прежнему работают по LOS и дальности оружия.
**Почему**. Если завязать целеуказание на видимость, меняются все критерии
приёмки, а отряд начинает «слепнуть» от поворота ствола — при том что `LaneClear`
и так требует чистой линии, то есть отсутствия стены. Чтобы стрельба в темноту
не выглядела абсурдом, мишень показывается, если освещена **или** если по ней уже
работает кто-то из отряда.
### A13. Shift — рывок, а не медленный шаг
**Спек**: 5 — «Shift (удерж.) — медленный шаг».
**Сделано**: базовая скорость снижена с 3.2 до 2.0, а Shift даёт рывок 3.2.
**Почему**. Заказчик просил «менее казуальное управление, помедленнее». Медленный
шаг при уже медленной базе — мёртвая клавиша. Инверсия сохраняет обе скорости из
спека (2.0 и 3.2), но делает осторожность режимом по умолчанию, а спешку —
осознанным решением игрока. Побочный эффект: проверка спуска на время полёта пули
обязана считать по МАКСИМАЛЬНОЙ скорости (`MaxAgentSpeed()`), иначе рывок союзника
не учитывается и возвращаются попадания по своим.
### A14. Дальность оружия 9.0 -> 7.2
**Сделано**: минус 20 % по требованию заказчика; вместе с ней отмасштабированы
`minEngageDist` (2.0 → 1.6) и кольца решателя (0.8/1.6/2.4 → 0.7/1.4/2.1),
а старт отряда сдвинут в (26.5, 26.5) ближе к кучке мишеней.
**Замер**: без сдвига старта и масштабирования колец сокращение дальности само по
себе давало 1 попадание по своим за 300 с и просадку uptime — агенты сбивались в
кучу у цели. С пакетом целиком: 0 попаданий, uptime в стоячем бою 100 %.
### A15. `candMinAlly` 0.55 -> 0.60
Порог «кандидат слишком близко к союзнику» был **меньше диаметра тела**
(2 × 0.28 = 0.56), то есть допускал позиции, где агенты физически пересекаются.
### A16. Клинок союзникам урона не наносит вообще
**Спек**: ближнего боя не описывает.
**Сделано**: сектор удара повторяет правило 6.3 в угловой форме — свой в
секторе — не бить. Но урон применяется **только к мишеням**: даже влезший под
лезвие союзник не получает ничего, взмах просто обрывается.
**Почему**. Соблазн был сделать «сильную» версию: клинок бьёт всех, а от
попаданий по своим спасает дисциплина сектора. Но у клинка нет времени полёта,
за которое можно передумать: союзник входит в дугу за 1/60 с уже после того, как
взмах начат, и в этот момент отменять нечего. «Сильная» версия означала бы
`FRIENDLY_HITS > 0` в любой плотной свалке, то есть провал главного критерия
прототипа ради механики, которой в спеке нет.
Обрыв взмаха даёт то же самое ощущение (боец отдёргивает клинок, серая вспышка,
`MELEE_ABORT` в метриках) и при этом не может сломать критерий 11.2.
**Замер**: 81 взмах за 30 с в плотной свалке, `FRIENDLY_HITS = 0`,
16 взмахов оборвано.
### A17. Старт замаха смотрит на союзника вперёд
**Сделано**: `MeleeArcClear` проверяет союзника в двух точках — где он сейчас и
где будет через `windup + strike`. Ровно та же поправка, что A2 для пули.
**Почему**. Взмах живёт 0.30 с, за которые союзник проходит полметра. Проверять
только текущее положение — значит начинать удары, которые всё равно придётся
обрывать.
**Замер** (сценарий M7, 30 с): без поправки и без резерва дуги — 21 оборванный
взмах из 77; с поправкой и резервом — 16 из 81, попаданий 81 против 81, убийств
23 против 23. То есть взмахов стало больше при том же результате.
### A18. Резерв в реестре линий получил явную полуширину
**Сделано**: у `FireLane` появилось поле `reserve`. Линия огня по-прежнему берёт
ширину из правила 6.3, клинок задаёт свою — долю бокового размаха сектора
(`meleeReserveFrac` 0.55).
**Почему**. Клинок резервирует отрезок вдоль оси удара, а поражает сектор ±50°.
Союзник, отжатый строго перпендикулярно оси, попадал ровно во фланг дуги — то
есть механизм расступания работал против себя.
**Замер**: `meleeReserve` 0 → 0.55 дало 16 оборванных взмахов вместо 21 при
большем числе взмахов, а худший `FIRE UPTIME` на стоянке маршрута — 23.3 %
вместо 0 % (стрелки перестали стоять в дугах и молчать).
### A19. Жёсткая граница огневой позиции (`postLeash`)
**Спек**: 6.5 — цена за удаление от якоря.
**Сделано**: дополнительно кандидат дальше `cohesionRadius + postLeash` от якоря
отбраковывается совсем.
**Почему**. Цена — мягкая: линия огня стоит 100 очков, а уход на 1.5 юнита за
круг — 36. Решатель всегда выбирал линию, и отряд расползался.
**Замер**: максимум удаления стрелка от диска 3.65 → 2.81 юнита. При этом
`FIRE UPTIME` на 180-секундном маршруте не упал, а вырос: на стоянках у мишеней
82.9 / 93.2 / 90.7 / 86.0 % — плотный отряд перекрывает друг другу линии реже,
чем расползшийся.
### A20. Мишени — тела, и они получают отдачу
**Сделано**: слой движения `TargetPushForce` (мишень расталкивает агентов) и
импульс от удара с возвратом на исходное место (`Target::home`).
**Почему**. Без расталкивания боец стоит **внутри** того, кого рубит, и удар не
читается вообще. Без отдачи попадание в упор визуально не отличается от промаха.
Возврат на `home` обязателен: расстановка уровня захардкожена, и уползание мишеней
поломало бы все замеры разом. Респавн дополнительно ставит `pos = home`.
### A21. Критерий 11.6 считает только выстрелы вперёд
**Было**: тест требовал, чтобы в коридоре передний агент стрелял минимум вдвое
чаще хвостовых, и считал ВСЕ выстрелы внутри прохода.
**Стало**: считаются только выстрелы **по ходу движения**. Выстрелы назад
выводятся в отчёт отдельно.
**Почему**. Диагностика `DIAG` показала, что почти все «нарушения» — это
хвостовой агент, стреляющий назад, в оставленную позади комнату, по совершенно
другой цели. Впереди по такой линии никого из отряда нет по построению, и к
правилу «не стрелять сквозь своего» это отношения не имеет. Порог ловил не
поведение, а геометрию расстановки мишеней.
**Замер**: на прежней базе тюнинга новый счёт даёт 4/0 (было 12/4), на текущей —
5/0. То есть критерий стал строже, а не мягче: вперёд хвост не стреляет вообще.
---
## B. Структурные (поведение не меняют)
### B1. Дополнительные файлы
Спек перечисляет структуру проекта; добавлены четыре файла:
| Файл | Зачем |
|---|---|
| `game.h/.cpp` | склейка подсистем и порядок шага. Без него это переехало бы в `main.cpp`, а спек требует, чтобы там были только окно и цикл |
| `render/scene.h/.cpp` | проход по полу и список сортировки; отдельно от `iso.h`, который остаётся чистой математикой |
| `core/input.h` | `InputState` нужен и `main.cpp`, и симуляции, и headless-прогонам |
| `debug/harness.h/.cpp` | прогоны `--headless` / `--accept` |
| `sim/loadout.h` | класс оружия и его параметры; сюда приедут разные пушки и апгрейды |
| `ai/melee.h/.cpp` | ближний бой; зеркало `firing_solver` для угловой геометрии |
### B2. У `Sprite` есть точка привязки
Спек: `Sprite { int w, h; std::vector<uint32_t> px; }`. Добавлены поля `ax, ay`
точка внутри спрайта, совмещаемая с проекцией мировой позиции. Иначе смещения
пришлось бы размазывать по коду отрисовки для каждого типа спрайта.
### B3. Параметры оружия синхронизируются из `Tuning` каждый шаг
`Agent::weapon` существует как в спеке, но перед движением поля копируются из
`g_tune` (`SyncLoadoutFromTuning`). Без этого runtime-подкрутка (раздел 8 спека)
не влияла бы на уже созданных агентов. Состояние ближнего боя — фаза, таймеры,
цель — при этом не трогается: копируются только параметры.
### B4. Dev-инструменты
`--headless`, `--accept`, `--probe`, `--set`, `--shot`, тумблеры `--no-solver` /
`--no-laneavoid`. В спеке их нет; они не влияют на игровую сборку и нужны, чтобы
критерии приёмки из раздела 11 проверялись цифрами, а не на глаз.
---
## Что осталось честно неидеальным
- `FIRE UPTIME` считает «нет живой цели в радиусе» простоем. На маршруте между
комнатами это даёт низкие значения, которые к качеству ИИ отношения не имеют.
- `SETTLE TIME` по той же причине растёт, если отряд остановился там, где стрелять
не по чему; в общем прогоне попадаются значения до 5.7 с. В обстановке, к
которой относится критерий 11.1 (мишени в радиусе), — 0.02 с.
- Пути агентов чисто локальные, без поиска пути. Отряд проходит узкие коридоры,
но лабиринт с тупиками не осилит — это вне рамок прототипа.

107
docs/09-acceptance.md Normal file
View File

@ -0,0 +1,107 @@
# 09 — Критерии приёмки
Раздел 11 спека. Воспроизвести: `build/bin/squad_proto.exe --accept`.
Сценарии — [src/debug/harness.cpp](../src/debug/harness.cpp), `RunAccept()`.
## Автоматическая проверка
Критерии 1, 6, 7, 8 описывают **правила стрельбы** и меряются на отряде из пяти
стрелков — так, как их формулировал спек. Ближний бой проверяется отдельным
блоком M7 и, главное, трёхминутным прогоном 11.2, где состав отряда обычный,
боевой (2 клинка + 3 ствола).
| # | Критерий | Статус | Цифры последнего прогона |
|---|---|---|---|
| 1 | ≥4 из 5 агентов имеют линию за ≤1.5 с после остановки | **PASS** | settle = 0.32 с, линий 5/5 |
| 2 | `FRIENDLY_HITS` = 0 за 3 минуты боя во всех режимах | **PASS** | 0 попаданий, смешанный отряд |
| 4 | агент с чужой линии огня уходит за ~0.3 с | **PASS** | максимум 0.35 с на арене (0.98 с по всем сценариям) |
| 5 | нет дёрганья: `postPos` не чаще раза в 0.6 с | **PASS** | 36 коммитов, 0 нарушений, минимальный интервал 0.75 с |
| 6 | в проходе 2 тайла — колонна, огонь не идёт сквозь своих | **PASS** | до 5 агентов в створе, поперёк 3.09 / вдоль 6.08 тайла, вперёд передний/хвост 77/9, сквозь союзника 0 выстрелов из 247 |
| 7 | ручной режим никогда не стреляет сквозь своих | **PASS** | 0 попаданий по своим за 30 с, назначенная цель 96 % времени |
| 8 | `FIRE UPTIME` в стоячем бою на арене ≥ 80 % | **PASS** | 100 % |
| 9 | стабильные 60 fps | **PASS** | симуляция 2.5 мкс + рендер 4.1 мс при бюджете 16.6 мс, fps 60 |
Ближний бой ([11-melee.md](11-melee.md)), сценарий 30 с у кучки мишеней:
| # | Критерий | Статус | Цифры |
|---|---|---|---|
| M7.1 | клинок доходит до цели и наносит урон | **PASS** | 55 взмахов, 52 попадания, 16 убийств, 14 оборвано своими; связка `slash 21 / backhand 19 / spin 15` |
| M7.2 | ближний бой не задевает своих | **PASS** | `FRIENDLY_HITS = 0` при 55 взмахах |
| M7.3 | у каждого класса свой круг вокруг диска | **PASS** | стрелки не дальше 3.49 от диска, клинки — 5.61 (лимиты 3.70 / 6.80) |
| M7.4 | лезвие не достаёт за пределы своей дуги | **PASS** | превышение сектора 0.0° (лимит 12°) |
M7.4 появился после починки геометрии удара: угловой допуск `asin(r/dist)`
вырождался на контактной дистанции в 90°, и боец рубил за спину — те самые
«удары на 360 градусов». Теперь проверка идёт по отрезку лезвия, а критерий
замеряет превышение разрешённого сектора и не даёт багу вернуться
([11-melee.md](11-melee.md)).
M7.2 держится не тюнингом, а конструкцией: урон применяется только к мишеням, а
влезший под лезвие союзник обрывает взмах ([08-decisions.md](08-decisions.md), A16).
Прогон 300 с (`--headless 300`), смешанный отряд: `FRIENDLY_HITS = 0`,
1471 выстрел, 142 взмаха / 146 попаданий лезвием, **63 убийства**.
Тот же маршрут чистым отрядом стрелков (`--melee 0`): 2100 выстрелов,
45 убийств. Клинки заметно поднимают урон отряда, не ломая главного правила.
`FIRE UPTIME` на стоянках маршрута: 82.9 / 93.2 / 90.7 / 86.0 % там, где есть
мишени, и 23.3 % на стоянке, где стрелять не по чему.
Тест 11.6 переписан ещё раз, и на этот раз по существу — с подсчёта «кто стрелял»
на проверку инварианта.
Причина была в другом месте, чем казалось. Сценарий тащил курсор непрерывно,
курсор обгонял отряд, натяжение поводка упиралось в упор, включался спринт — а в
спринте отряд по замыслу не стреляет вовсе. Тест показывал «0 выстрелов вперёд» и
падал, проверяя пустой коридор. После поднятия `sprintAt` до 0.85 удержание
направления спринт больше не включает, и отряд идёт через проход **с боем**
как и должен.
Область замера тоже была неверной: проход глубиной три тайла, а бойцов пятеро,
и колонна физически длиннее самого прохода — внутрь него целиком она не
помещается никогда. Меряется теперь створ прохода с подходами по обе стороны,
на всей проводке, а не только на стоянке.
Заодно выброшен критерий «передний стреляет чаще заднего». В проходе шириной два
тайла у заднего есть законные диагональные линии мимо переднего, и требовать их
отсутствия — значит требовать от отряда быть глупее, чем он есть. «Задние не
стреляют» — это следствие правила «не сквозь своего», а не отдельное правило.
Поэтому проверяется сам инвариант: берётся отрезок **реально сделанного**
выстрела и независимой геометрией проверяется, не задел ли он тело союзника.
Результат: 0 из 247 выстрелов прошли сквозь своего, колонна 6.08 тайла вдоль
против 3.09 поперёк, и передний всё равно ведёт огонь с перевесом 77 против 9 —
теперь это наблюдение, а не условие прохождения.
## Проверяется глазами / по коду
| # | Критерий | Как подтверждено |
|---|---|---|
| 3 | в движении стреляют только те, у кого чистая линия | по построению: спуск требует `hasLane`; в оверлее `F2` видно, что часть агентов идёт с жёлтой линией и молчит |
| 10 | вся сцена нарисована своим растеризатором | в `render/` нет ни одного raylib-примитива; raylib вызывается только в `ScreenBlitter` (`UpdateTexture` + `DrawTexturePro`) и в `debug/overlay.cpp`, который dev-only и снимается по `F1` |
Скриншот боевого кадра ([docs/combat.png](combat.png)): отряд в пятне света,
у каждого своя зелёная линия, ни одна не проходит через своего, трассеры в полёте,
дальние мишени видны только тлеющими глазами. `FRIENDLY_HITS 0`.
## Оговорки, которые стоит знать
- Критерий 1 и метрика `SETTLE TIME` осмысленны только когда в радиусе есть живые
мишени. В общем маршруте попадаются значения до 5.7 с — это остановки там, где
стрелять не по чему, а не медленное расхождение.
- Критерий 8 меряется в стоячем бою рядом с кучкой. При остановке на краю
дальности честные значения ниже: часть агентов физически не достаёт цель.
После сокращения дальности до 7.2 худшая стоянка маршрута даёт ~30 % — это
остановка там, где кучка уже вне радиуса, а не деградация решателя.
- `FRIENDLY_HITS = 0` достигается связкой из четырёх вещей: резерв линий, включая
перекрытые своим; резерв шире порога блокировки; учёт времени полёта на спуске;
пуля не летит дальше проверенного отрезка. Отключение любой из них возвращает
попадания по своим — цифры в [08-decisions.md](08-decisions.md).
- `FIRE UPTIME` усредняется только по стрелкам, `MELEE reach` — только по клинкам.
Смешивать доли времени двух разных занятий в одно среднее бессмысленно.
- «Макс. время в чужой линии» на смешанном отряде доходит до 3.9 с против 1.3 с
на чистых стрелках. Это боец с клинком, который стоит вплотную к мишени и тем
самым перекрывает стрелку линию: расступиться он не может, не бросив свою
работу. Правило при этом не нарушается — стрелок просто не стреляет и
перерешивает позицию. Критерий 11.4 меряется на открытой арене чистым отрядом
стрелков, где такой ситуации нет по построению.

200
docs/10-atmosphere.md Normal file
View File

@ -0,0 +1,200 @@
# 10 — Хоррор-слой: свет, туман войны, трассеры, напряжение
Весь этот слой — **render-only**. Симуляция о нём не знает: `RenderGame` принимает
`const Game&`, поэтому ни свет, ни трассеры, ни тряска камеры физически не могут
изменить состояние симуляции. Headless-прогоны (`--accept`, `--headless`) `View`
не создают вообще, и цифры приёмки остаются цифрами симуляции.
> Раздел 12 исходного спека запрещал освещение и туман войны. Заказчик изменил
> требования — теперь это ядро подачи. Правила стрельбы (раздел 6) не тронуты.
## Порядок кадра
```
Clear(чёрный) ступень 0 рампы == цвет очистки
-> свет и туман (Lighting::Update)
-> пол: BlitShaded, тёмные тайлы пропускаются
-> список сортировки: стены, мишени, агенты — тоже BlitShaded
-> глаза мишеней (аддитивно)
-> трассеры, вспышки дула, искры (аддитивно)
-> ОДИН слитый пост-проход: виньетка, зерно, сердцебиение
-> Present
-> debug-оверлей (raylib, dev-only, F1)
```
## Почему свет вплавлен в блит, а не сделан пост-проходом
Соблазн очевидный: пройти по 129600 пикселям, для каждого получить мировую точку
через `iso::ScreenToWorld` и умножить на освещённость. Так делать нельзя.
Спрайт стены имеет привязку `ay = TILE_HALF_H + TILE_H = 24`, то есть верхняя
грань поднята на 16 px над ромбом своего тайла. Обратная проекция этого пикселя
даёт `ScreenToWorld(0, -16) = (-1, -1)`**ровно соседний тайл на северо-запад**.
Пост-проход осветил бы крыши стен чужой видимостью: чёрные крыши на освещённых
стенах и светящийся ореол на тайл северо-западнее каждой стены. Чинится это
только буфером высот на пиксель, то есть честным depth-буфером, которого в
проекте нет и заводить его ради этого не стоит.
Поэтому свет модулирует цвет **на этапе блита спрайта**: спрайт целиком светится
от своего тайла, и артефакт исчезает конструктивно, без единой проверки.
## Модель света
Одна скалярная величина на точку мира плюс **рампа** — таблица
`g_shadeLut[32][3][256]`, переводящая уровень в цвет (подход COLORMAP из Doom).
Цветокоррекция зашита в таблицу: в полумраке картинка уходит в холодный синий,
на свету нейтральна, во вспышке выбивается в тёплый пересвет. На пиксель
остаются три выборки из таблицы и ноль умножений.
```
tile = min(1, max(память, видимость * sightBrightness) + вспышки)
```
| Состояние | Значение | Как выглядит |
|---|---|---|
| никогда не видели | 0 | чистый чёрный, тайл вообще не рисуется |
| видели раньше | `fogMemoryLevel` | тускло и холодно, сущностей нет |
| видно сейчас | `sightBrightness` | нормальная яркость |
| вспышка выстрела | выше | тёплый пересвет |
Диапазон выше `sightBrightness` намеренно зарезервирован под динамический свет:
выстрел всегда «выбивает» кадр вверх и в тепло.
### Билинейка без швов
Узловая сетка 49×49 (`corner`), узел = среднее смежных тайлов. Патч света на
тайл раскладывается в координатах спрайта без перекрёстного члена:
```
Bu = c10-c00, Bv = c01-c00, K = c00-c10-c01+c11
a = c00 - (Bu-Bv)/2 - K/4 b = (Bu-Bv+K)/TILE_W c = (Bu+Bv)/TILE_H
e = -K/(TILE_W*TILE_W) f = K/(TILE_H*TILE_H)
L(px,py) = a + b*px + c*py + e*px^2 + f*py^2
```
Это **точная** билинейка по четырём вершинам ромба: подстановка (16,0), (32,8),
(0,8), (16,16) даёт ровно c00, c10, c01, c11. Значит на общем ребре двух соседних
тайлов значения совпадают и швов нет. По строке считается прямыми разностями —
во внутреннем цикле два сложения на пиксель.
Блочность добивают четыре вещи: узловая сетка, точная билинейка, временное
сглаживание (`fogFadeRate`) и упорядоченный дизеринг Байера 4×4 — 32 ступени
рампы выглядят как ~200, полос не видно.
## Видимость
Для каждого агента перебираются тайлы в квадрате радиуса обзора; для каждого —
затухание по дальности × max(конус взгляда, круговой «личный» ореол), затем
проверка LOS уже существующим `Tilemap::RaycastBlocked`. Рейкаст делается
последним: дальность и конус отсекают большинство тайлов даром.
Стену нельзя проверить лучом до её центра — DDA считает вход в саму стену
перекрытием. Поэтому стены освещаются отдельным проходом: берут максимум от
соседей-нестен, `WALL_LIGHT_PASSES = 3` раза с затуханием. Без этого разделитель
`y=13..15` толщиной три тайла остаётся чёрным изнутри, и его южная грань висит
в пустоте.
**Инвариант:** стена никогда не темнее самого яркого соседа-пола. Иначе тёмная
стена будет пропущена как «ступень 0», и сквозь её силуэт проступит освещённый
пол тайла (tx1, ty1), который рисуется раньше по глубине.
### Конус зрения и ИИ
Конус — **чисто игроцкая условность**. ИИ по-прежнему работает по LOS и дальности
оружия, `LaneClearFrom` не изменён. Если завязать выбор целей на видимость,
поменяются все критерии приёмки, а отряд начнёт «слепнуть» из-за поворота ствола.
Чтобы стрельба в темноту не выглядела абсурдом, мишень показывается, если она
освещена **или** если по ней уже работает кто-то из отряда.
## Трассеры
Физика пули не тронута: снаряд летит ровно по проверенному отрезку, правило
«не стрелять сквозь своих» и `FRIENDLY_HITS = 0` не задеты. Менялся только вид.
- **штрих** от `pos` назад по вектору движения на `tracerLength`, но не дальше
точки вылета — `AddBeam` рисует ядро с градиентом цвета и боковым ореолом;
- **вспышка у дула**`AddGlowDisc` + короткий язык пламени, `muzzleTime`;
- **искра попадания** — своя для стены, цели, убийства;
- **пурпурная искра** = попадание по своему. Это не украшение, а индикатор:
в норме её не должно появиться ни разу.
Всё аддитивно (`AddSat`), поэтому светится и поверх темноты — трассер сам себе
источник света.
## Дуга клинка
`DrawMeleeArcs` в [src/render/fx.cpp](../src/render/fx.cpp). В отличие от
трассеров, дуга читается **прямо из состояния агентов**, а не из журнала событий:
фаза удара живёт десятые доли секунды и попадает в каждый кадр, а привязка к
живой позиции важнее — боец в момент удара едет вперёд рывком.
- **замах** — холодная стальная дуга уводится назад за край сектора и
разгорается. Это телеграф, и он обязан быть виден;
- **удар** — серп из четырёх концентрических дуг: внешний край раскалён,
внутренние тускнеют к рукояти. Одна линия на 480×270 читается палкой, четыре —
взмахом. Хвост — доля пройденной дуги, а не вся: серп, а не кольцо;
- **восстановление** — серп стоит на месте и быстро гаснет.
В изометрии круг — эллипс, поэтому дуга набирается сэмплами **мировых** точек
(`AddWorldArc`), а не экранной окружностью.
Через журнал идут только дискретные события: `MELEE_SWING` (вспышка света),
`MELEE_HIT` (оранжевая искра, сильная отдача камеры — удар всегда в упор),
`MELEE_ABORT` (тусклая серая вспышка: свой влез под лезвие). Клинок дульной
вспышки не даёт, но искры от удара освещают не хуже — иначе весь ближний бой
шёл бы в кромешной темноте.
Подробности механики — [11-melee.md](11-melee.md).
## Канал «симуляция → эффекты»
`sim/events.h`: кольцевой журнал `CombatEventLog`. Симуляция только пишет,
правила не читают, в headless передаётся `nullptr`.
Почему журнал, а не флаг `Agent::fired`: за один кадр рендера проходит **до
`MAX_STEPS_PER_FRAME` шагов симуляции**. Флаг, выставленный на шаге 1 и затёртый
на шаге 2, эффект не увидит. По той же причине `Dread` считает дельты
накопительных счётчиков метрик, а не смотрит на флаги.
## Напряжение (Dread)
Складывается из близости мишени, близости **невидимой** мишени (страшнее всего),
толпы и того, сколько агентов вообще заняты боем. «Занят» — это линия огня у
стрелка или цель в зоне удара у клинка: боец, достающий врага, беспомощным не
выглядит и напряжение снимает так же. Сглаживание асимметричное: страх набегает
быстро (`dreadAttack`), отпускает медленно (`dreadRelease`).
Кормит: глубину виньетки, амплитуду зерна, частоту сердцебиения, отдачу камеры.
Отдача добавляется к `scene.ox/oy` сразу после `scene.Begin` — только там она
согласована и с полом, и со списком сортировки, и с оверлеем.
## Пост-проход
Один слитый цикл по 129600 пикселям: виньетка (статическое поле + таблица кадра),
зерно с амплитудой обратно яркости (в тенях шумит, на свету чисто), давление
сердцебиения. Света на входе **нет** — он уже вплавлен на блите.
Границы: индекс таблицы насыщения равен `128 + канал + шум`, поэтому амплитуда
зерна зажата 127 — иначе в Release пошли бы случайные яркие точки, которые
легко принять за художественный замысел.
## Псевдослучайность
`render/noise.h` — единственное место. Чистые хеш-функции без состояния:
значение зависит только от координаты и номера кадра. Общего генератора нет,
синхронизировать нечего, детерминизм симуляции не задет.
## Цена
Замер на Intel Iris Xe, Release: **1.43.5 мс** на кадр при бюджете 16.6 мс,
то есть 821 %. Верхняя граница — когда память карты уже разведана и освещённых
тайлов много; fps при этом держится 60. Симуляция — 1.9 мкс. Ни одна из оптимизаций (предзатенённые
спрайты, пересчёт видимости через кадр, уменьшение числа ступеней рампы) не нужна.
Самое дорогое — не пост-проход, а `BlitShaded` сцены. Пропуск полностью тёмных
тайлов это частично компенсирует: в тумане рисуется заметно меньше половины карты.
## Аварийные тумблеры
`F5` — выключить весь хоррор-слой (свет, туман, пост) и вернуть прежний кадр.
`F6` — показать диски обзора. Полезно, когда непонятно, баг это или темнота.

222
docs/11-melee.md Normal file
View File

@ -0,0 +1,222 @@
# 11 — Ближний бой
Второй класс оружия. Файлы: [src/sim/loadout.h](../src/sim/loadout.h) (типы и
профили), [src/ai/melee.cpp](../src/ai/melee.cpp) (цель, подход, дисциплина
сектора), `Game::UpdateMelee` в [src/game.cpp](../src/game.cpp) (машина
состояний и урон), [src/render/fx.cpp](../src/render/fx.cpp) (дуга).
## Главная мысль
Ближний бой — это **то же правило, что и стрельба, в угловой форме**. Ничего
нового в этику отряда он не приносит:
| | стрелок | боец ближнего боя |
|---|---|---|
| зона поражения | отрезок «дуло → цель» | сектор `reach` × `arcDeg` вокруг оси удара |
| правило безопасности | свой в отрезке → не стрелять | свой в секторе → не бить |
| проверка вперёд | `AllyInterceptsShot` (успеет ли влезть за время полёта) | экстраполяция союзника на `windup + strike` |
| срыв в последний момент | пуля уже летит, срывать нечего | взмах **обрывается** |
| резерв в реестре линий (6.6) | отрезок до цели | ось удара с явной полушириной |
| свой круг вокруг диска | `cohesionRadius` | `meleeCohesionRadius`, заметно больший |
| выбор позиции | решатель огневых позиций | подход вплотную к цели |
Из этого следует главное свойство: **клинок физически не может поднять
`FRIENDLY_HITS`**. Союзникам он урона не наносит вообще, а правило сектора
существует не ради урона, а ради того, чтобы отряд выглядел как отряд.
## Состав отряда
`Squad::meleeCount` (по умолчанию `DEFAULT_MELEE_COUNT` = 2) — сколько **первых
слотов строя** держат клинок. В клине это остриё и левое крыло, то есть те, кто
и так упирается в противника первым. Ставится до `Squad::Init`; из командной
строки — `--melee N`.
## Три фазы удара
```
IDLE ──цель в reach, корпус довёрнут, сектор чист──> WINDUP ──> STRIKE ──> RECOVER ──> IDLE
0.17 с 0.13 с 0.24 с
интервал между НАЧАЛАМИ замахов: 0.60 с
```
- **WINDUP** — замах. Ось удара уже зафиксирована и больше не доворачивается:
доворот на этой фазе — это самонаведение, только клинком. Скорость ×0.20,
ноги вкопаны. Игрок обязан успевать читать замах, иначе удар выглядит не
ударом, а случайным списанием HP.
- **STRIKE** — активное окно. Лезвие метёт сектор от одного края к другому,
сторона захода чередуется от взмаха к взмаху. Здесь же **рывок**: seek,
separation и расступание молчат, боец едет по оси удара со своей скоростью
и своим ускорением. Стены — единственное, что его останавливает.
- **RECOVER** — боец открыт и медленный (×0.55).
## Связка ударов
Чередование «влево-вправо» — это не связка, а метроном: два удара, между которыми
нет разницы ни на глаз, ни по последствиям. Связка обязана иметь **форму**.
Таблица — `MELEE_COMBO` в [src/tuning.h](../src/tuning.h), множители к базовым
характеристикам клинка:
| # | Удар | Сектор | Урон | Отброс | Замах | Пауза | Смысл |
|---|---|---|---|---|---|---|---|
| 1 | `slash` | ×1.00 | ×1.00 | ×1.00 | ×1.00 | ×1.00 | косой заход, с него всё начинается |
| 2 | `backhand` | ×0.85 | ×1.00 | ×0.85 | ×0.55 | ×0.50 | возврат с обратной стороны, быстро и почти без паузы |
| 3 | `spin` | ×1.70 | ×1.75 | ×2.30 | ×1.50 | ×1.35 | раскрутка: широкая, тяжёлая, с долгой расплатой |
Серия держится `meleeComboWindow` (1.1 с) после удара. Не продолжил вовремя —
начинаешь с первого. Итог по времени: три удара за 1.71 с вместо трёх за 1.80 с,
но суммарный урон выше в 1.25 раза — связка выгоднее ровного метронома, если её
успевают довести.
Раскрутку в тесноте **просто не дадут начать**: её сектор проверяется той же
дисциплиной, что и обычный удар, а он шире почти вдвое. Это не ограничение, а
цена: широкий замах требует места. В замере связки: `slash 21 / backhand 19 /
spin 15` — финиш доходит реже, и это видно в цифрах, а не на словах.
Параметры взмаха **запекаются на старте замаха** (`MeleeWeapon::BakeSwing`).
Причина техническая и важная: базовые поля переписываются со снаряжения каждый
шаг (`SyncLoadout`), и без запекания удар менял бы ширину и урон прямо в полёте.
## Удар на ходу
Бой на ходу — нормальный режим, а не исключение. Ограничение ровно одно: в
**спринте** боец не гоняется за целями (`Squad::GoalFor` возвращает слот строя),
но если цель сама оказалась под рукой — бьёт. Отдельного запрета «на бегу не
драться» нет.
При этом на текущих числах поводка удержание направления спринт и не включает:
отряд идёт быстрым шагом и работает (см. [05-squad-ai.md](05-squad-ai.md),
равновесие поводка).
## Урон идёт по пройденной полосе, а не по факту удара
Каждый шаг считаются два угла лезвия: где оно было в начале шага и где стало в
конце. Цель получает урон, если полоса шириной `meleeBladeDeg` прошла через неё
**за этот шаг** (`BladeSweepHits`). Проверяется и «стоит на цели прямо сейчас»,
и «проскочило насквозь за 1/60 с» — за шаг дуга проходит заметный угол.
### Почему это точная геометрия, а не угловой допуск
Лезвие — **отрезок** от бойца длиной `reach`, повернувшийся за шаг. Проверка
работает с ним напрямую: попало ли направление на цель в заметённую полосу, а
если нет — не задел ли цель край отрезка в крайних положениях.
Раньше здесь стоял угловой допуск `asin(r / dist)`. Он корректно описывает
угловой размер тела, но как **допуск к сектору** вырождается: при `dist -> r`
`asin` даёт 90°, сектор разрастается в полуплоскость, и боец рубит за спину.
В фазе рывка он как раз проезжает вплотную — отсюда и брались удары «на 360
градусов». Отрезок такого не допускает: для цели позади ближайшая точка лезвия —
рукоять, а до неё ровно `dist`, что больше радиуса цели.
Инвариант закреплён критерием **M7.4**: замеряется, на сколько градусов
попадание вышло за разрешённый сектор ЭТОГО взмаха. Норма — ноль.
Смысл: то, что видно на экране, и то, что получило урон, — одна и та же дуга.
Разовый расчёт «в начале взмаха по всему сектору» дал бы попадания раньше, чем
лезвие туда доедет, и это чувствуется.
Одна мишень получает урон **один раз за взмах** (`hitMask`), но один взмах может
задеть несколько — мишени стоят кучками, и это ровно то, ради чего клинок нужен.
Сквозь стену не рубит: перед уроном идёт `RaycastBlocked`.
## Дисциплина сектора
Проверяется дважды, как и у ствола:
1. **на старте замаха** — сектор `reach` × `arcDeg` обязан быть чист. Союзник
проверяется в двух точках: где он сейчас и где будет через `windup + strike`
(`meleeStartPredict`). Без этого треть взмахов начиналась впустую;
2. **каждый шаг активного окна** — если свой всё-таки влез под лезвие, взмах
**обрывается**: серая вспышка, `MELEE_ABORT`, сразу в `RECOVER`.
Оборванные взмахи считаются метрикой (`meleeAborts`) и видны в оверлее. Это не
баг, а видимая цена скученности: примерно каждый пятый взмах в плотной свалке.
Угловой размер тела союзника считается честно (`asin(r / dist)`), поэтому
вплотную союзник перекрывает почти весь сектор — так и должно быть.
## Свой, больший круг
Стрелок держится в `cohesionRadius` (2.1) от диска, и решатель **жёстко**
отбраковывает огневые позиции дальше `cohesionRadius + postLeash`. Боец ближнего
боя берёт цель, только если она внутри `meleeCohesionRadius` (5.6) от диска и не
дальше `meleeSeekRadius` от него самого. Нет такой цели — возвращается в слот
строя.
Поэтому классы живут в двух разных кругах вокруг одного диска: стрелки кучкой,
клинки — на вылазке, но на поводке. Замеряется критерием M7.3.
Двое с клинками не вцепляются в одну мишень: цена цели растёт на
`meleeClaimPenalty` за каждого своего, уже её выбравшего. Ближняя цель всё ещё
выигрывает, но не любой ценой — вдвоём на одну это оборванные взмахи друг об
друга и вдвое меньше урона (замер: 37 взмахов / 31 попадание против 74 / 75).
## Мишени получают отдачу
Удар отбрасывает мишень (`meleeKnockback`), импульс гасится и мишень возвращается
на своё место (`home`). Без отдачи попадание в упор ничем не отличается от
промаха. Расстановка уровня захардкожена, поэтому «уползание» мишеней исключено
конструктивно: возврат идёт всегда, а респавн ставит `pos = home`.
Отдельно появился слой движения `TargetPushForce`: мишень — тело, а не пустое
место. Без него боец стоял **внутри** того, кого рубит.
## Взаимодействие со стрельбой
- Боец ближнего боя — обычный союзник для `LaneClearFrom`. Стрелки сквозь него
не стреляют, и решатель разводит их так, чтобы работать мимо него.
- Пока идёт замах или удар, боец **не расступается** из чужих линий. Смысла нет:
стрелок всё равно не выстрелит (правило 6.3 запрещает), а удар сорвётся.
Расступается он до и после.
- Рывок учтён в `AllyInterceptsShot` отдельной экстраполяцией: на замахе боец
почти неподвижен и через мгновение выстреливает по уже известной оси, о чём
его текущая скорость ничего не говорит. Проверяются обе точки — где он есть и
куда прыгнет. Именно поэтому `MaxAgentSpeed()` намеренно **не** включает
скорость рывка: она бы раздула запас для всех выстрелов сразу.
## Как это выглядит
[src/render/fx.cpp](../src/render/fx.cpp), `DrawMeleeArcs`. Дуга читается прямо
из состояния агентов, а не из журнала событий: фаза живёт десятые доли секунды,
её видно в каждом кадре, и привязка к живой позиции важнее — боец в момент удара
едет вперёд.
- **WINDUP** — холодная стальная дуга уводится назад за край сектора и
разгорается. Это телеграф.
- **STRIKE** — серп из четырёх концентрических дуг: внешний край раскалён,
внутренние тускнеют к рукояти. Одна линия на 480×270 читается палкой, четыре —
взмахом. Хвост — доля пройденной дуги (`bladeTrailFrac`), а не вся: серп, а не
кольцо. На голове — раскалённая радиальная черта и точка.
- **RECOVER** — серп стоит на месте и быстро гаснет.
В изометрии круг — эллипс, поэтому дуга рисуется сэмплами мировых точек, а не
экранной окружностью.
События `MELEE_SWING` / `MELEE_HIT` / `MELEE_ABORT` идут по тому же каналу
`sim/events.h`, что и выстрелы: искры, вспышки света и тряска камеры (`meleeShake`
заметно сильнее, чем на выстрел — удар всегда в упор).
Класс читается силуэтом: у бойца капсула шире и со стальным клинком. Цвет занят
номером агента, поэтому классом он быть не может.
## Метрики
`FIRE UPTIME` усредняется **только по стрелкам**: линии огня у клинка не бывает
по определению, и его ноль занижал бы метрику стрельбы. Отдельно считается
`MELEE reach` — доля времени в зоне удара. Для `SETTLE TIME` «в бою» = стрелок
при линии **или** клинок при цели.
Критерии M7.1M7.3 в `--accept` — [09-acceptance.md](09-acceptance.md).
## Куда приедут пушки и апгрейды
[src/sim/loadout.h](../src/sim/loadout.h). Сейчас профиль ровно один на класс и
целиком копируется из `tuning.h` каждый шаг (`SyncLoadoutFromTuning`) — так
работает runtime-подкрутка. Когда профилей станет несколько, здесь встанет
таблица и модификаторы поверх неё, а весь остальной код продолжит читать
`Agent::weapon` / `Agent::melee` и не заметит разницы.
Что уже вынесено в параметры и годится под модификаторы: досягаемость, ширина
сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание,
скорость и ускорение рывка, множители скорости на замахе и восстановлении,
радиус своего круга.

BIN
docs/combat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

BIN
lib/libgcc_s_dw2-1.dll Normal file

Binary file not shown.

BIN
lib/libstdc++-6.dll Normal file

Binary file not shown.

58
main.code-workspace Normal file
View File

@ -0,0 +1,58 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"files.associations": {
"raylib.h": "c",
"math.h": "c",
"blocks.h": "c",
"stdio.h": "c",
"*.m": "c",
"limits": "cpp",
"array": "cpp",
"atomic": "cpp",
"*.tcc": "cpp",
"cctype": "cpp",
"clocale": "cpp",
"cmath": "cpp",
"cstdarg": "cpp",
"cstddef": "cpp",
"cstdint": "cpp",
"cstdio": "cpp",
"cstdlib": "cpp",
"cwchar": "cpp",
"cwctype": "cpp",
"deque": "cpp",
"unordered_map": "cpp",
"vector": "cpp",
"exception": "cpp",
"algorithm": "cpp",
"memory": "cpp",
"memory_resource": "cpp",
"optional": "cpp",
"string": "cpp",
"string_view": "cpp",
"system_error": "cpp",
"tuple": "cpp",
"type_traits": "cpp",
"utility": "cpp",
"fstream": "cpp",
"initializer_list": "cpp",
"iosfwd": "cpp",
"iostream": "cpp",
"istream": "cpp",
"new": "cpp",
"ostream": "cpp",
"sstream": "cpp",
"stdexcept": "cpp",
"streambuf": "cpp",
"cinttypes": "cpp",
"typeinfo": "cpp"
},
"git.autoRepositoryDetection": false,
"git.enabled": false
}
}

BIN
preview.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

342
src/ai/firing_solver.cpp Normal file
View File

@ -0,0 +1,342 @@
#include "ai/firing_solver.h"
namespace
{
constexpr float BIG_F = 1e18f;
// Дуло: muzzleOffset юнита от точки from в сторону цели.
inline Vec2 MuzzleAt(const Agent& shooter, Vec2 from, Vec2 targetPos)
{
return from + Normalized(targetPos - from) * shooter.gun.def.muzzleOffset;
}
// Полураствор конуса выстрела, выраженный как тангенс: боковое отклонение
// пули на расстоянии L от дула равно tan * L.
inline float SpreadTan(const Agent& shooter)
{
return std::tan(std::min(shooter.SpreadRad(), 1.2f));
}
// Свободен ли конус «дуло -> цель» от своих.
//
// Раньше проверялся отрезок: пуля летела ровно по нему, и этого хватало.
// Теперь у выстрела есть разброс, и проверять надо ровно ту область, куда
// пуля может уйти, — конус. Ослабления правила 6.3 тут нет, наоборот: зона
// проверки стала шире отрезка ровно настолько, насколько шире стал выстрел.
// Расширение растёт вдоль линии (у дула конус нулевой), поэтому союзник,
// стоящий вплотную к стрелку сбоку, лишнего запрета не создаёт.
bool ConeClearOfAllies(const Agent& shooter, Vec2 muzzle, int shooterIndex, Vec2 targetPos,
const SolverContext& ctx)
{
const float half = shooter.gun.def.laneHalfWidth + g_tune.safetyMargin;
const float segLen = Distance(muzzle, targetPos);
const float k = SpreadTan(shooter) * segLen;
for (int i = 0; i < ctx.agentCount; ++i)
{
if (i == shooterIndex) continue;
const Agent& ally = ctx.agents[i];
float t = 0.0f;
const float d = DistancePointToSegment(ally.pos, muzzle, targetPos, &t);
if (d < ally.bodyRadius + half + k * t) return false;
}
return true;
}
// Почему линии нет: свой или стена (для F2-оверлея).
void ClassifyBlock(Vec2 from, int shooterIndex, Vec2 targetPos, const SolverContext& ctx,
bool& byAlly, bool& byWall)
{
const Agent& shooter = ctx.agents[shooterIndex];
const Vec2 muzzle = MuzzleAt(shooter, from, targetPos);
byAlly = !ConeClearOfAllies(shooter, muzzle, shooterIndex, targetPos, ctx);
byWall = ctx.map->RaycastBlocked(muzzle, targetPos);
}
// Куда союзник МОЖЕТ уехать за время t. Обычно это линейная экстраполяция
// скорости, но боец ближнего боя на замахе стоит почти неподвижно и через
// мгновение выстреливает рывком по УЖЕ ИЗВЕСТНОЙ оси удара — скорость об этом
// ещё ничего не говорит. Поэтому для него проверяются обе точки: где он есть
// и куда он прыгнет. Иначе выстрел «мимо стоящего» находит его в полёте.
int PredictAllyPositions(const Agent& ally, float t, Vec2 out[2])
{
out[0] = ally.pos + ally.vel * t;
if (!ally.IsMelee() || !ally.melee.Committed()) return 1;
out[1] = ally.pos + FromAngle(ally.melee.dir) * (g_tune.meleeLungeSpeed * t);
return 2;
}
} // namespace
// -----------------------------------------------------------------------------
// 6.3 LaneClear
// -----------------------------------------------------------------------------
bool LaneClearFrom(Vec2 from, int shooterIndex, Vec2 targetPos, const SolverContext& ctx)
{
const Agent& shooter = ctx.agents[shooterIndex];
const Vec2 muzzle = MuzzleAt(shooter, from, targetPos);
// 1. свои — по конусу разброса, а не по осевому отрезку
if (!ConeClearOfAllies(shooter, muzzle, shooterIndex, targetPos, ctx)) return false;
// 2. геометрия
if (ctx.map->RaycastBlocked(muzzle, targetPos)) return false;
// 3. дистанция — своя у каждого ствола
if (Distance(muzzle, targetPos) > shooter.gun.def.range) return false;
return true;
}
bool AllyInterceptsShot(Vec2 muzzle, Vec2 targetPos, int shooterIndex, const SolverContext& ctx)
{
const Agent& shooter = ctx.agents[shooterIndex];
const Vec2 dir = Normalized(targetPos - muzzle);
const float speed = std::max(0.001f, shooter.gun.def.bulletSpeed);
const float tImpact = Distance(muzzle, targetPos) / speed;
// Пуля летит внутри конуса, поэтому её «толщина» растёт с пройденным путём.
const float spreadK = SpreadTan(shooter);
// Идём по времени полёта: где будет пуля и где будет союзник.
// Радиус безопасности растёт со временем — союзник может и повернуть,
// а линейная экстраполяция скорости это не ловит.
constexpr int SAMPLES = 6;
for (int k = 0; k <= SAMPLES; ++k)
{
const float t = tImpact * (float(k) / float(SAMPLES));
const Vec2 bulletPos = muzzle + dir * (speed * t);
for (int i = 0; i < ctx.agentCount; ++i)
{
if (i == shooterIndex) continue;
const Agent& ally = ctx.agents[i];
const float r = ally.bodyRadius + shooter.gun.def.bulletRadius + g_tune.safetyMargin +
g_tune.shotPredictSlack * MaxAgentSpeed() * t +
spreadK * speed * t;
Vec2 predicted[2];
const int n = PredictAllyPositions(ally, t, predicted);
for (int q = 0; q < n; ++q)
if (DistanceSq(bulletPos, predicted[q]) < r * r) return true;
}
}
return false;
}
int NearestAliveTarget(Vec2 from, const SolverContext& ctx, float searchRadius)
{
const std::vector<Target>& targets = *ctx.targets;
int best = -1;
float bestD = searchRadius;
for (int i = 0; i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
const float d = Distance(from, targets[i].pos);
if (d < bestD)
{
bestD = d;
best = i;
}
}
return best;
}
// -----------------------------------------------------------------------------
// Выбор цели: 6.4 (ближайшая с чистой линией) + 6.8 (приоритет назначенной).
// -----------------------------------------------------------------------------
void EvaluateAgentLane(Agent& a, int index, const SolverContext& ctx)
{
const std::vector<Target>& targets = *ctx.targets;
a.hasLane = false;
a.priorityLane = false;
a.laneIntent = false;
a.currentTargetId = -1;
a.targetBlockedByAlly = false;
a.targetBlockedByWall = false;
// --- ручной режим: сначала назначенная цель ---
if (ctx.assignedTargetId >= 0 && ctx.assignedTargetId < int(targets.size()) &&
targets[ctx.assignedTargetId].alive)
{
const Vec2 tp = targets[ctx.assignedTargetId].pos;
a.laneEnd = tp;
if (LaneClearFrom(a.pos, index, tp, ctx))
{
a.currentTargetId = ctx.assignedTargetId;
a.hasLane = true;
a.priorityLane = true;
return;
}
ClassifyBlock(a.pos, index, tp, ctx, a.targetBlockedByAlly, a.targetBlockedByWall);
a.laneIntent = a.targetBlockedByAlly && !a.targetBlockedByWall &&
Distance(a.pos, tp) <= a.gun.def.range + a.gun.def.muzzleOffset;
// Вторичные цели — только в движении и только при включённом флаге (6.8).
if (!(a.state == AgentState::ADVANCING && g_tune.freeFireSecondary)) return;
}
// --- авто: ближайшая цель, до которой линия чиста ---
int best = -1;
float bestD = BIG_F;
for (int i = 0; i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
const float d = Distance(a.pos, targets[i].pos);
if (d >= bestD || d > a.gun.def.range + a.gun.def.muzzleOffset) continue;
if (!LaneClearFrom(a.pos, index, targets[i].pos, ctx)) continue;
best = i;
bestD = d;
}
if (best >= 0)
{
a.currentTargetId = best;
a.hasLane = true;
a.priorityLane = (ctx.assignedTargetId < 0);
a.laneEnd = targets[best].pos;
return;
}
// Линии нет — для оверлея показываем ближайшую цель и причину.
if (ctx.assignedTargetId < 0)
{
const int nearest = NearestAliveTarget(a.pos, ctx, a.gun.def.range);
if (nearest >= 0)
{
a.laneEnd = targets[nearest].pos;
ClassifyBlock(a.pos, index, targets[nearest].pos, ctx,
a.targetBlockedByAlly, a.targetBlockedByWall);
// Линию перекрыл только свой -> намерение стрелять остаётся, и линия
// всё равно резервируется (6.6), иначе перекрывшего ничто не сдвинет.
a.laneIntent = a.targetBlockedByAlly && !a.targetBlockedByWall;
}
}
}
// -----------------------------------------------------------------------------
// 6.5 Оценка кандидата
// -----------------------------------------------------------------------------
float ScoreCandidate(Vec2 cand, const Agent& a, int index, const SolverContext& ctx,
int bestTargetId)
{
const std::vector<Target>& targets = *ctx.targets;
float score = 0.0f;
if (bestTargetId >= 0 && targets[bestTargetId].alive &&
LaneClearFrom(cand, index, targets[bestTargetId].pos, ctx))
score += g_tune.sLaneClear;
int shootable = 0;
int total = 0;
float nearestDist = BIG_F;
for (int i = 0; i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
++total;
nearestDist = std::min(nearestDist, Distance(cand, targets[i].pos));
if (LaneClearFrom(cand, index, targets[i].pos, ctx)) ++shootable;
}
if (total > 0) score += g_tune.sCoverage * float(shootable) / float(total);
if (ctx.lanes)
score -= g_tune.sBlockAlly * float(ctx.lanes->CountBlocked(index, cand, a.bodyRadius));
score -= g_tune.sDistCost * Distance(cand, a.pos);
score -= g_tune.sCohesionCost *
std::max(0.0f, Distance(cand, ctx.anchor) - g_tune.cohesionRadius);
if (nearestDist < g_tune.minEngageDist) score -= g_tune.sTooClose;
return score;
}
// -----------------------------------------------------------------------------
// 6.5 Решатель: 3 кольца x 12 направлений + текущая позиция, коммит с гистерезисом
// -----------------------------------------------------------------------------
bool SolveFiringPosition(Agent& a, int index, const SolverContext& ctx, SolveResult& out)
{
const std::vector<Target>& targets = *ctx.targets;
out.count = 0;
out.bestIndex = -1;
out.committed = false;
// Цель решателя: назначенная (ручной режим) либо ближайшая достижимая.
int bestTargetId = -1;
if (ctx.assignedTargetId >= 0 && ctx.assignedTargetId < int(targets.size()) &&
targets[ctx.assignedTargetId].alive)
bestTargetId = ctx.assignedTargetId;
else
bestTargetId = NearestAliveTarget(a.pos, ctx, a.gun.def.range + g_tune.ring3);
out.bestTargetId = bestTargetId;
if (bestTargetId < 0) return false; // стрелять не по чему — стоим
const float rings[SOLVER_RINGS] = {g_tune.ring1, g_tune.ring2, g_tune.ring3};
auto push = [&](Vec2 p, bool forceValid) {
if (out.count >= SOLVER_CANDIDATES) return;
const int i = out.count++;
out.candidates[i] = p;
out.valid[i] = true;
out.scores[i] = 0.0f;
if (!forceValid)
{
// Отбраковка: вне карты / в стене / за поводком от диска /
// слишком близко к другому агенту.
if (p.x < 1.0f || p.y < 1.0f || p.x > float(MAP_W) - 1.0f || p.y > float(MAP_H) - 1.0f)
out.valid[i] = false;
else if (Distance(p, ctx.anchor) > g_tune.cohesionRadius + g_tune.postLeash)
out.valid[i] = false;
else if (ctx.map->CircleHitsWall(p, a.bodyRadius))
out.valid[i] = false;
else
{
for (int k = 0; k < ctx.agentCount; ++k)
{
if (k == index) continue;
if (Distance(p, ctx.agents[k].pos) < g_tune.candMinAlly)
{
out.valid[i] = false;
break;
}
}
}
}
if (out.valid[i]) out.scores[i] = ScoreCandidate(p, a, index, ctx, bestTargetId);
};
push(a.pos, true); // индекс 0 — текущая позиция, для сравнения
out.currentScore = out.scores[0];
for (int r = 0; r < SOLVER_RINGS; ++r)
for (int k = 0; k < SOLVER_DIRS; ++k)
push(a.pos + FromAngle(float(k) * (TWO_PI_F / float(SOLVER_DIRS))) * rings[r], false);
out.bestScore = out.currentScore;
for (int i = 1; i < out.count; ++i)
{
if (!out.valid[i]) continue;
if (out.scores[i] > out.bestScore)
{
out.bestScore = out.scores[i];
out.bestIndex = i;
}
}
// Коммит с гистерезисом — иначе агент дёргается между двумя точками.
if (out.bestIndex > 0 && out.bestScore > out.currentScore + g_tune.hysteresis)
{
a.postPos = out.candidates[out.bestIndex];
a.commitTimer = g_tune.commitTime;
out.committed = true;
}
return true;
}

68
src/ai/firing_solver.h Normal file
View File

@ -0,0 +1,68 @@
#pragma once
// -----------------------------------------------------------------------------
// Ядро прототипа (спек 6.36.5): проверка линии огня, выбор цели,
// решатель огневых позиций с гистерезисом.
//
// Всё здесь — чистые функции над состоянием мира. Модуль ai/ НЕ знает
// ни про рендер, ни про изометрию, ни про ввод.
// -----------------------------------------------------------------------------
#include <vector>
#include "ai/lane_registry.h"
#include "sim/agent.h"
#include "sim/target.h"
#include "world/tilemap.h"
struct SolverContext
{
const Tilemap* map = nullptr;
const Agent* agents = nullptr;
int agentCount = 0;
const std::vector<Target>* targets = nullptr;
const LaneRegistry* lanes = nullptr;
Vec2 anchor{};
int assignedTargetId = -1; // ручной режим (6.8); -1 = авто
};
// --- 6.3 ---------------------------------------------------------------------
// Линия от дула стрелка (в точке from, ствол смотрит на цель) до targetPos.
// Порядок проверок как в спеке: свои -> геометрия -> дистанция.
bool LaneClearFrom(Vec2 from, int shooterIndex, Vec2 targetPos, const SolverContext& ctx);
// Успеет ли союзник влезть под пулю за время её полёта.
//
// 6.3 проверяет линию в МОМЕНТ выстрела, а 6.9 делает пулю настоящим снарядом:
// до 0.4 с полёта, за которые союзник проходит больше метра. Реактивного
// расступания (6.6) тут не хватает — союзник может быть чист в момент нажатия
// и войти в коридор уже потом. Поэтому спуск дополнительно проверяет
// сближение пули и союзника как двух равномерно движущихся точек.
// Само правило 6.3 не ослабляется: см. docs/08-decisions.md.
bool AllyInterceptsShot(Vec2 muzzle, Vec2 targetPos, int shooterIndex, const SolverContext& ctx);
// --- выбор цели (6.4 / 6.8) --------------------------------------------------
// Заполняет currentTargetId, hasLane, priorityLane и диагностику для оверлея.
void EvaluateAgentLane(Agent& a, int index, const SolverContext& ctx);
// Ближайшая живая мишень в радиусе searchRadius (или -1).
int NearestAliveTarget(Vec2 from, const SolverContext& ctx, float searchRadius);
// --- 6.5 ---------------------------------------------------------------------
struct SolveResult
{
Vec2 candidates[SOLVER_CANDIDATES]{};
float scores[SOLVER_CANDIDATES]{};
bool valid[SOLVER_CANDIDATES]{};
int count = 0;
int bestIndex = -1;
float bestScore = 0.0f;
float currentScore = 0.0f;
bool committed = false;
int bestTargetId = -1;
};
float ScoreCandidate(Vec2 cand, const Agent& a, int index, const SolverContext& ctx,
int bestTargetId);
// true, если решатель отработал (нашёл bestTarget и оценил кандидатов).
bool SolveFiringPosition(Agent& a, int index, const SolverContext& ctx, SolveResult& out);

55
src/ai/lane_registry.cpp Normal file
View File

@ -0,0 +1,55 @@
#include "ai/lane_registry.h"
void LaneRegistry::Add(int shooter, Vec2 from, Vec2 to, float reserve)
{
if (count >= NUM_AGENTS) return;
lanes[count++] = FireLane{from, to, shooter, reserve};
}
Vec2 LaneRegistry::AvoidanceForce(int agentIndex, Vec2 pos, float bodyRadius, float maxSpeed) const
{
// Резерв шире, чем «тело уже в линии»: агент должен отходить ЗАРАНЕЕ,
// иначе он успевает влезть под пулю, которая уже летит.
const float wide = BlockRadius(bodyRadius) * g_tune.laneReserveScale;
Vec2 force{};
for (int i = 0; i < count; ++i)
{
const FireLane& lane = lanes[i];
if (lane.shooter == agentIndex) continue;
const float threshold = (lane.reserve > 0.0f) ? (bodyRadius + lane.reserve) : wide;
const float d = DistancePointToSegment(pos, lane.from, lane.to);
if (d >= threshold) continue;
const Vec2 axis = Normalized(lane.to - lane.from);
if (LengthSq(axis) < 0.5f) continue;
// В сторону ближайшего края линии.
const Vec2 n = Perp(axis);
const float side = Dot(pos - lane.from, n);
Vec2 away = (side >= 0.0f) ? n : Vec2{-n.x, -n.y};
if (std::fabs(side) < 1e-4f) // ровно на оси — разводим детерминированно
away = (agentIndex & 1) ? n : Vec2{-n.x, -n.y};
// Чем ближе к оси, тем сильнее; laneAvoidStrength делает отклик резче.
const float strength = Clampf((1.0f - d / threshold) * g_tune.laneAvoidStrength, 0.0f, 1.0f);
force += away * (maxSpeed * strength);
}
return force;
}
int LaneRegistry::CountBlocked(int exceptShooter, Vec2 pos, float bodyRadius,
bool shootingOnly) const
{
const float narrow = BlockRadius(bodyRadius);
int n = 0;
for (int i = 0; i < count; ++i)
{
if (lanes[i].shooter == exceptShooter) continue;
if (shootingOnly && lanes[i].reserve > 0.0f) continue; // это сектор клинка
const float threshold = (lanes[i].reserve > 0.0f) ? (bodyRadius + lanes[i].reserve) : narrow;
if (DistancePointToSegment(pos, lanes[i].from, lanes[i].to) < threshold) ++n;
}
return n;
}

51
src/ai/lane_registry.h Normal file
View File

@ -0,0 +1,51 @@
#pragma once
// -----------------------------------------------------------------------------
// Реестр активных линий огня (спек 6.6).
//
// Каждый шаг собираются отрезки «дуло стреляющего -> его цель». Любой агент,
// чьё тело попало в чужую линию, получает импульс ПЕРПЕНДИКУЛЯРНО линии в
// сторону ближайшего края. Работает в обоих состояниях отряда.
//
// Модуль ai/ не знает ни про рендер, ни про изометрию.
// -----------------------------------------------------------------------------
#include "core/math.h"
#include "tuning.h"
struct FireLane
{
Vec2 from{}; // дуло
Vec2 to{}; // цель
int shooter = -1; // индекс агента-стрелка
// Абсолютная полуширина резерва. 0 = обычная линия огня, ширина берётся
// из правила 6.3. Клинок задаёт свою: его сектор шире отрезка, и союзник,
// отжатый строго вбок от оси, оказался бы ровно во фланге дуги.
float reserve = 0.0f;
};
struct LaneRegistry
{
FireLane lanes[NUM_AGENTS];
int count = 0;
void Clear() { count = 0; }
void Add(int shooter, Vec2 from, Vec2 to, float reserve = 0.0f);
// Порог, при котором тело считается «в линии».
static float BlockRadius(float bodyRadius)
{
return bodyRadius + g_tune.laneHalfWidth + g_tune.safetyMargin;
}
// Импульс расступания для агента agentIndex (свои линии игнорируются).
Vec2 AvoidanceForce(int agentIndex, Vec2 pos, float bodyRadius, float maxSpeed) const;
// Сколько чужих линий перекрывает точка pos (для оценки кандидата, 6.5).
//
// shootingOnly отсекает резервы клинков. Нужно ровно одному потребителю —
// критерию 11.4 «агент уходит с чужой ЛИНИИ ОГНЯ за ~0.3 с»: сектор клинка
// резервируется заведомо шире опасной зоны, и мерить по нему то же время
// означало бы мерить другую величину. Решателю, наоборот, нужны оба:
// стоять на дороге у топора так же плохо, как и под пулей.
int CountBlocked(int exceptShooter, Vec2 pos, float bodyRadius,
bool shootingOnly = false) const;
};

192
src/ai/melee.cpp Normal file
View File

@ -0,0 +1,192 @@
#include "ai/melee.h"
namespace
{
constexpr float BIG_F = 1e18f;
// Угловой полуразмер тела радиуса r, видимого с расстояния dist.
// Вплотную союзник перекрывает почти весь сектор — так и должно быть.
float AngularHalfSize(float dist, float r)
{
return std::asin(Clampf(r / std::max(dist, r), 0.0f, 1.0f));
}
// Годится ли мишень как цель ближнего боя: жива, внутри СВОЕГО круга бойца,
// не дальше meleeSeekRadius от него самого и видна (за стену не бегаем —
// путей в прототипе нет, а обход угла даёт слой wallAvoidance).
bool MeleeCandidate(const Agent& a, const SolverContext& ctx, int id)
{
const Target& t = (*ctx.targets)[id];
if (!t.alive) return false;
if (Distance(t.pos, ctx.anchor) > g_tune.meleeCohesionRadius) return false;
if (Distance(t.pos, a.pos) > g_tune.meleeSeekRadius) return false;
if (ctx.map->RaycastBlocked(a.pos, t.pos)) return false;
return true;
}
} // namespace
void EvaluateMeleeTarget(Agent& a, int index, const SolverContext& ctx)
{
const std::vector<Target>& targets = *ctx.targets;
// Линии огня у бойца нет и быть не может — обнуляем всю стрелковую часть,
// иначе он попадёт и в реестр линий, и в FIRE UPTIME.
a.hasLane = false;
a.priorityLane = false;
a.laneIntent = false;
a.targetBlockedByAlly = false;
a.targetBlockedByWall = false;
MeleeWeapon& m = a.melee;
// Пока идёт замах или удар, цель не переназначается: ось уже зафиксирована.
if (m.Committed() && m.targetId >= 0 && targets[m.targetId].alive)
{
a.currentTargetId = m.targetId;
a.laneEnd = targets[m.targetId].pos;
m.inReach = Distance(a.pos, targets[m.targetId].pos) <=
m.reach + targets[m.targetId].radius;
return;
}
m.targetId = -1;
m.inReach = false;
// Назначенная игроком цель приоритетна и здесь (6.8), но только если она
// проходит те же условия — иначе боец убежал бы через всю карту.
int best = -1;
if (ctx.assignedTargetId >= 0 && ctx.assignedTargetId < int(targets.size()) &&
MeleeCandidate(a, ctx, ctx.assignedTargetId))
{
best = ctx.assignedTargetId;
}
else
{
float bestCost = BIG_F;
for (int i = 0; i < int(targets.size()); ++i)
{
if (!MeleeCandidate(a, ctx, i)) continue;
// Цена = дистанция + штраф за каждого своего, уже вцепившегося
// в эту мишень. Ближняя цель всё ещё выигрывает, но не любой ценой:
// вдвоём на одну — это оборванные взмахи друг об друга и ноль урона.
float cost = Distance(a.pos, targets[i].pos);
for (int k = 0; k < ctx.agentCount; ++k)
{
if (k == index) continue;
if (ctx.agents[k].IsMelee() && ctx.agents[k].melee.targetId == i)
cost += g_tune.meleeClaimPenalty;
}
if (cost >= bestCost) continue;
best = i;
bestCost = cost;
}
}
m.targetId = best;
a.currentTargetId = best;
if (best < 0) return;
a.laneEnd = targets[best].pos;
m.inReach = Distance(a.pos, targets[best].pos) <= m.reach + targets[best].radius;
m.arcBlockedByAlly = m.inReach && !MeleeArcClear(a, index, AngleOf(targets[best].pos - a.pos), ctx);
}
Vec2 MeleeGoal(const Agent& a, const SolverContext& ctx, Vec2 fallbackSlot)
{
const MeleeWeapon& m = a.melee;
if (m.targetId < 0) return fallbackSlot;
const Target& t = (*ctx.targets)[m.targetId];
// Встаём вплотную, но не внутрь: reach считается от центра тела, поэтому
// с такой дистанции удар достаёт с запасом.
const float stand = t.radius + a.bodyRadius + g_tune.meleeGap;
const Vec2 away = Normalized(a.pos - t.pos, FromAngle(a.facing + PI_F));
return t.pos + away * stand;
}
bool MeleeArcClear(const Agent& a, int index, float axisAngle, const SolverContext& ctx)
{
const MeleeWeapon& m = a.melee;
const float half = m.HalfArc();
const float reach = m.reach + g_tune.meleeAllyMargin;
// Взмах живёт windup + strike секунд, и всё это время союзник продолжает
// идти. Проверять только текущее положение — значит начинать удары, которые
// всё равно придётся обрывать. Это ровно та же поправка, что и
// AllyInterceptsShot для пули: правило не ослабляется, оно смотрит вперёд.
const float horizon = (m.windup + m.strike) * g_tune.meleeStartPredict;
for (int i = 0; i < ctx.agentCount; ++i)
{
if (i == index) continue;
const Agent& ally = ctx.agents[i];
const Vec2 probe[2] = {ally.pos, ally.pos + ally.vel * horizon};
for (const Vec2& q : probe)
{
const Vec2 d = q - a.pos;
const float dist = Length(d);
if (dist > reach + ally.bodyRadius) continue;
if (dist < 1e-4f) return false; // стоим в одной точке — бить нельзя
const float pad = AngularHalfSize(dist, ally.bodyRadius + g_tune.meleeAllyMargin);
if (std::fabs(AngleDiff(axisAngle, AngleOf(d))) <= half + pad) return false;
}
}
return true;
}
bool BladeSweepHits(Vec2 from, float angPrev, float angCur, float bladeHalf, float reach,
Vec2 p, float pRadius)
{
const Vec2 d = p - from;
const float dist = Length(d);
if (dist > reach + pRadius) return false;
// Тела перекрываются — попадает любой взмах, и это честно: цель обнимает
// рукоять. Случай редкий, отсечён отдельно, чтобы ниже не делить на ноль.
if (dist <= pRadius) return true;
// Дальше — ТОЧНАЯ геометрия: лезвие это ОТРЕЗОК от бойца длиной reach,
// повернувшийся за шаг с angPrev на angCur.
//
// Раньше здесь стоял угловой допуск asin(r/dist), и он вырождался: на
// контактной дистанции (dist -> pRadius) asin даёт 90 градусов, «сектор»
// разрастался в полуплоскость, и боец рубил за спину. В фазе рывка он как
// раз проезжает вплотную — отсюда и брались удары «на 360 градусов».
// Отрезок такого не допускает: для цели позади ближайшая точка лезвия —
// рукоять, а до неё ровно dist, что больше pRadius.
const float ang = AngleOf(d);
const float sweep = AngleDiff(angPrev, angCur); // короткая дельта за шаг
const float rel = AngleDiff(angPrev, ang); // где цель относительно старта
// Направление на цель попало в заметённую за шаг полосу — значит лезвие
// прошло через неё (дистанцию уже проверили сверху).
const float lo = std::min(0.0f, sweep) - bladeHalf;
const float hi = std::max(0.0f, sweep) + bladeHalf;
if (rel >= lo && rel <= hi) return true;
// Иначе цель могла быть задета краем: проверяем оба крайних положения.
const float ends[2] = {angPrev, angCur};
for (float a : ends)
if (DistancePointToSegment(p, from, from + FromAngle(a) * reach) <= pRadius) return true;
return false;
}
Vec2 MeleeStrikeEnd(const Agent& a, const SolverContext& ctx)
{
const MeleeWeapon& m = a.melee;
// На замахе и ударе ось уже зафиксирована, до него — направление на цель.
float axis = m.dir;
if (!m.Committed())
{
if (m.targetId < 0) return a.pos;
axis = AngleOf((*ctx.targets)[m.targetId].pos - a.pos);
}
return a.pos + FromAngle(axis) * m.reach;
}

37
src/ai/melee.h Normal file
View File

@ -0,0 +1,37 @@
#pragma once
// -----------------------------------------------------------------------------
// Ближний бой: выбор цели, точка подхода, дисциплина сектора.
//
// Это ровно тот же контракт, что и у стрельбы (ai/firing_solver.h), только
// геометрия угловая, а не линейная:
//
// стрелок боец ближнего боя
// ---------------------------- --------------------------------------------
// отрезок дуло -> цель сектор reach x arcDeg вокруг оси удара
// свой в отрезке -> не стрелять свой в секторе -> не бить
// решатель огневых позиций подход вплотную к цели
// cohesionRadius (свой круг) meleeCohesionRadius (СВОЙ, больший круг)
//
// Модуль ai/ по-прежнему не знает ни про рендер, ни про изометрию, ни про ввод.
// -----------------------------------------------------------------------------
#include "ai/firing_solver.h"
// Выбор цели ближнего боя. Заполняет melee.targetId / melee.inReach и
// currentTargetId (чтобы разворот ствола, оверлей и рендер работали как есть).
// hasLane у бойца всегда false: стрелять ему нечем.
void EvaluateMeleeTarget(Agent& a, int index, const SolverContext& ctx);
// Куда идти бойцу. Есть цель — вплотную к ней, нет — обратно в слот строя.
Vec2 MeleeGoal(const Agent& a, const SolverContext& ctx, Vec2 fallbackSlot);
// В секторе замаха нет своих. Аналог LaneClearFrom для клинка.
bool MeleeArcClear(const Agent& a, int index, float axisAngle, const SolverContext& ctx);
// Прошло ли лезвие через круг (p, pRadius) за шаг, на котором его угол
// сместился с angPrev на angCur. bladeHalf — половина ширины самой полосы.
bool BladeSweepHits(Vec2 from, float angPrev, float angCur, float bladeHalf, float reach,
Vec2 p, float pRadius);
// Конец отрезка, который боец резервирует в реестре линий (6.6): ось удара
// длиной reach. Свои расступаются с траектории клинка так же, как с линии огня.
Vec2 MeleeStrikeEnd(const Agent& a, const SolverContext& ctx);

36
src/core/hash.h Normal file
View File

@ -0,0 +1,36 @@
#pragma once
// -----------------------------------------------------------------------------
// Чистые хеш-функции без состояния. Значение зависит ТОЛЬКО от аргументов.
//
// Это не генератор случайных чисел: общего состояния нет, «посеять» нечего,
// последовательность вызовов ни на что не влияет. Поэтому такой хеш можно
// звать и из симуляции, не ломая детерминизм: --headless по-прежнему выдаёт
// один и тот же прогон, а разброс выстрела воспроизводится по номеру выстрела
// и индексу стрелка (см. sim/loadout.h и docs/08-decisions.md).
//
// Рендер использует те же функции через render/noise.h.
// -----------------------------------------------------------------------------
#include <cstdint>
inline uint32_t HashU32(uint32_t x)
{
x ^= x >> 16;
x *= 0x7FEB352Du;
x ^= x >> 15;
x *= 0x846CA68Bu;
x ^= x >> 16;
return x;
}
inline uint32_t Hash2(uint32_t x, uint32_t y) { return HashU32(x * 0x27D4EB2Du ^ (y + 0x165667B1u)); }
inline uint32_t Hash3(uint32_t x, uint32_t y, uint32_t z)
{
return HashU32(Hash2(x, y) ^ (z * 0x9E3779B9u));
}
// [0, 1)
inline float Hash01(uint32_t h) { return float(h & 0x00FFFFFFu) * (1.0f / 16777216.0f); }
// [-1, 1)
inline float HashSigned(uint32_t h) { return Hash01(h) * 2.0f - 1.0f; }

17
src/core/input.h Normal file
View File

@ -0,0 +1,17 @@
#pragma once
// -----------------------------------------------------------------------------
// Ввод, который влияет на симуляцию. Собирается в main.cpp из raylib,
// в headless-прогонах заполняется скриптом. Отладочные клавиши (F1..F4, тюнинг)
// сюда не попадают — они не влияют на симуляцию.
// -----------------------------------------------------------------------------
#include "core/math.h"
struct InputState
{
Vec2 moveScreen{}; // WASD в ЭКРАННЫХ осях, -1..1
bool sprint = false; // Shift — рывок
bool toggleAimMode = false; // Space (фронт нажатия)
float aimTurn = 0.0f; // стрелки влево/вправо, -1..1
bool cycleTarget = false; // Tab (фронт нажатия)
int setFormation = -1; // 1/2/3 -> 0/1/2, иначе -1
};

107
src/core/math.h Normal file
View File

@ -0,0 +1,107 @@
#pragma once
// -----------------------------------------------------------------------------
// Базовая математика прототипа. Только декартов мир (top-down), никакой изометрии.
// Header-only: всё inline, никаких аллокаций.
// -----------------------------------------------------------------------------
#include <cmath>
#include <algorithm>
constexpr float PI_F = 3.14159265358979323846f;
constexpr float TWO_PI_F = 6.28318530717958647692f;
constexpr float DEG2RAD_F = PI_F / 180.0f;
struct Vec2
{
float x = 0.0f;
float y = 0.0f;
};
inline Vec2 operator+(Vec2 a, Vec2 b) { return {a.x + b.x, a.y + b.y}; }
inline Vec2 operator-(Vec2 a, Vec2 b) { return {a.x - b.x, a.y - b.y}; }
inline Vec2 operator*(Vec2 a, float s) { return {a.x * s, a.y * s}; }
inline Vec2 operator*(float s, Vec2 a) { return {a.x * s, a.y * s}; }
inline Vec2 operator/(Vec2 a, float s) { return {a.x / s, a.y / s}; }
inline Vec2 operator-(Vec2 a) { return {-a.x, -a.y}; }
inline Vec2& operator+=(Vec2& a, Vec2 b) { a.x += b.x; a.y += b.y; return a; }
inline Vec2& operator-=(Vec2& a, Vec2 b) { a.x -= b.x; a.y -= b.y; return a; }
inline Vec2& operator*=(Vec2& a, float s) { a.x *= s; a.y *= s; return a; }
inline float Dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
inline float Cross(Vec2 a, Vec2 b) { return a.x * b.y - a.y * b.x; }
inline float LengthSq(Vec2 a) { return a.x * a.x + a.y * a.y; }
inline float Length(Vec2 a) { return std::sqrt(LengthSq(a)); }
inline float DistanceSq(Vec2 a, Vec2 b) { return LengthSq(b - a); }
inline float Distance(Vec2 a, Vec2 b) { return Length(b - a); }
// Перпендикуляр (поворот на +90°).
inline Vec2 Perp(Vec2 a) { return {-a.y, a.x}; }
inline Vec2 Normalized(Vec2 a, Vec2 fallback = {0.0f, 0.0f})
{
const float len = Length(a);
return (len > 1e-6f) ? Vec2{a.x / len, a.y / len} : fallback;
}
// Ограничить длину вектора сверху.
inline Vec2 ClampLength(Vec2 a, float maxLen)
{
const float len = Length(a);
if (len > maxLen && len > 1e-6f) return a * (maxLen / len);
return a;
}
inline float Clampf(float v, float lo, float hi) { return v < lo ? lo : (v > hi ? hi : v); }
inline float Lerpf(float a, float b, float t) { return a + (b - a) * t; }
inline Vec2 Lerp(Vec2 a, Vec2 b, float t) { return {Lerpf(a.x, b.x, t), Lerpf(a.y, b.y, t)}; }
// --- углы -------------------------------------------------------------------
inline float AngleOf(Vec2 v) { return std::atan2(v.y, v.x); }
inline Vec2 FromAngle(float a) { return {std::cos(a), std::sin(a)}; }
// Приводит угол к (-pi, pi].
inline float WrapPi(float a)
{
while (a > PI_F) a -= TWO_PI_F;
while (a <= -PI_F) a += TWO_PI_F;
return a;
}
// Кратчайшая разница углов to-from в (-pi, pi].
inline float AngleDiff(float from, float to) { return WrapPi(to - from); }
inline float RotateToward(float cur, float target, float maxDelta)
{
const float d = AngleDiff(cur, target);
if (std::fabs(d) <= maxDelta) return WrapPi(target);
return WrapPi(cur + (d > 0.0f ? maxDelta : -maxDelta));
}
// --- сегменты ---------------------------------------------------------------
struct Segment
{
Vec2 a;
Vec2 b;
};
// Ближайшая точка отрезка ab к p; outT (если нужен) — параметр вдоль отрезка [0..1].
inline Vec2 ClosestPointOnSegment(Vec2 p, Vec2 a, Vec2 b, float* outT = nullptr)
{
const Vec2 ab = b - a;
const float lenSq = LengthSq(ab);
float t = 0.0f;
if (lenSq > 1e-12f) t = Clampf(Dot(p - a, ab) / lenSq, 0.0f, 1.0f);
if (outT) *outT = t;
return a + ab * t;
}
inline float DistancePointToSegment(Vec2 p, Vec2 a, Vec2 b, float* outT = nullptr)
{
return Distance(p, ClosestPointOnSegment(p, a, b, outT));
}
inline float DistancePointToSegment(Vec2 p, const Segment& s, float* outT = nullptr)
{
return DistancePointToSegment(p, s.a, s.b, outT);
}

603
src/debug/harness.cpp Normal file
View File

@ -0,0 +1,603 @@
#include "debug/harness.h"
#include <chrono>
#include <cstdarg>
#include <cstdio>
#include "game.h"
// Перевод экранных осей ввода в мировые — единственное место,
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
#include "render/iso.h"
namespace
{
// Мини-форматтер для строк вердикта (raylib в этот модуль не тащим).
const char* Fmt(const char* fmt, ...)
{
static char buf[4][256];
static int slot = 0;
slot = (slot + 1) % 4;
va_list args;
va_start(args, fmt);
vsnprintf(buf[slot], sizeof(buf[slot]), fmt, args);
va_end(args);
return buf[slot];
}
// --- маршрут для --headless ---------------------------------------------------
struct Waypoint
{
float x, y;
float hold; // сколько стоять по прибытии, сек
int formation; // -1 = не менять
};
const Waypoint kRoute[] = {
{24.0f, 26.0f, 8.0f, 0}, // стоячий бой на арене у кучки из 5
{31.0f, 20.0f, 6.0f, -1}, // вплотную к кучке
{31.0f, 16.5f, 0.0f, 1}, // ко входу в северный коридор (2 тайла)
{31.0f, 11.5f, 2.0f, -1}, // сквозь коридор
{24.5f, 7.5f, 8.0f, 0}, // бой в северной комнате
{31.0f, 11.5f, 0.0f, -1},
{31.0f, 17.5f, 2.0f, -1}, // обратно на арену
{20.0f, 29.0f, 4.0f, 2},
{16.0f, 31.0f, 0.0f, 1}, // ко входу в западный коридор
{11.0f, 31.0f, 2.0f, -1}, // сквозь него
{ 8.5f, 38.5f, 8.0f, 0}, // бой в западной комнате
{11.0f, 31.0f, 0.0f, -1},
{17.0f, 31.0f, 2.0f, -1},
{24.0f, 26.0f, 6.0f, 0},
};
constexpr int kRouteCount = int(sizeof(kRoute) / sizeof(kRoute[0]));
constexpr float kArriveDist = 0.7f;
constexpr float kWaypointTimeout = 20.0f;
// Мировое направление -> экранные оси: ровно то, что делает игрок клавишами.
Vec2 MoveInputToward(const Game& g, Vec2 goal)
{
return Normalized(iso::WorldToScreen(Normalized(goal - g.squad.anchor)));
}
// -----------------------------------------------------------------------------
// Наблюдатель: собирает то, что нельзя увидеть в одном кадре.
// * сколько агент непрерывно торчит в чужой линии огня (критерий 11.4);
// * как часто меняется postPos (критерий 11.5 — «нет дёрганья»).
// -----------------------------------------------------------------------------
struct Watcher
{
float residence[NUM_AGENTS] = {}; // текущее время внутри чужой линии
float maxResidence = 0.0f;
// «Дёрганье» = решатель перекоммитил postPos раньше, чем истёк commitTime.
// Коммит виден по скачку commitTimer вверх. Смена состояния отряда сбрасывает
// отсчёт: это новая обстановка, а не дрожание.
float prevCommitTimer[NUM_AGENTS] = {};
float sinceCommit[NUM_AGENTS] = {};
bool hadCommit[NUM_AGENTS] = {};
float minCommitInterval = 1e9f;
int commits = 0;
int jitterViolations = 0;
AgentState prevState = AgentState::HOLDING;
// Граница сценария. Накопленные максимумы и счётчики остаются (они про весь
// прогон), а вот «когда был прошлый коммит» — нет: следующий сценарий это
// другой Game с другим отрядом, и мерить между ними интервал бессмысленно.
// Без этого первый же коммит нового сценария выглядел дёрганьем.
void BeginScenario()
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
hadCommit[i] = false;
sinceCommit[i] = 0.0f;
prevCommitTimer[i] = 0.0f;
residence[i] = 0.0f;
}
prevState = AgentState::HOLDING;
}
void Observe(const Game& g, float dt)
{
const bool stateChanged = (g.squad.state != prevState);
prevState = g.squad.state;
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = g.squad.agents[i];
// Только линии огня: 11.4 про них, сектор клинка резервируется
// заведомо шире опасной зоны (см. ai/lane_registry.h).
const bool inside = g.lanes.CountBlocked(i, a.pos, a.bodyRadius, true) > 0;
residence[i] = inside ? residence[i] + dt : 0.0f;
maxResidence = std::max(maxResidence, residence[i]);
if (stateChanged) hadCommit[i] = false;
sinceCommit[i] += dt;
if (a.commitTimer > prevCommitTimer[i] + 1e-4f) // произошёл коммит
{
++commits;
if (hadCommit[i])
{
minCommitInterval = std::min(minCommitInterval, sinceCommit[i]);
if (sinceCommit[i] < g_tune.commitTime - FIXED_DT)
{
++jitterViolations;
std::printf(" JITTER #%d dt=%.3f state=%s melee=%d prio=%d lane=%d "
"post(%.2f,%.2f) t=%.2f\n",
i, double(sinceCommit[i]),
g.squad.state == AgentState::ADVANCING ? "ADV" : "HOLD",
int(a.IsMelee()), int(a.priorityLane), int(a.hasLane),
double(a.postPos.x), double(a.postPos.y),
double(g.simTime));
}
}
hadCommit[i] = true;
sinceCommit[i] = 0.0f;
}
prevCommitTimer[i] = a.commitTimer;
}
}
};
int CountLanes(const Game& g)
{
int n = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
if (g.squad.agents[i].hasLane) ++n;
return n;
}
void PrintVerdict(const char* name, bool pass, const char* detail)
{
std::printf("[%s] %-52s %s\n", pass ? "PASS" : "FAIL", name, detail);
}
} // namespace
// -----------------------------------------------------------------------------
// --headless: маршрут + метрики
// -----------------------------------------------------------------------------
int RunHeadless(const HarnessOptions& opt)
{
Game game;
game.enableSolver = opt.solver;
game.enableLaneAvoid = opt.laneAvoid;
game.squad.meleeCount = opt.meleeCount; // до Init: состав раздаётся там
game.squad.pistolCount = opt.pistolCount;
game.Init();
Watcher watch;
const int totalSteps = int(opt.seconds / FIXED_DT);
int wp = 0;
bool holding = false;
float phaseClock = 0.0f;
int lastFormation = -1;
double simSeconds = 0.0;
float nextReport = 10.0f;
float worstSettle = -1.0f;
float worstHoldUptime = 1e9f;
std::printf("headless: %.0f s, solver=%d laneAvoid=%d melee=%d/%d\n", double(opt.seconds),
int(opt.solver), int(opt.laneAvoid), game.squad.meleeCount, NUM_AGENTS);
std::printf(" t friendly shots kills uptime%% settle lanes\n");
for (int step = 0; step < totalSteps; ++step)
{
const Waypoint& p = kRoute[wp];
const float distToGoal = Distance(game.squad.anchor, {p.x, p.y});
InputState in;
if (!holding) in.moveScreen = MoveInputToward(game, {p.x, p.y});
if (p.formation >= 0 && p.formation != lastFormation)
{
in.setFormation = p.formation;
lastFormation = p.formation;
}
game.in = in;
const auto t0 = std::chrono::steady_clock::now();
game.Step(FIXED_DT);
simSeconds += std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
watch.Observe(game, FIXED_DT);
phaseClock += FIXED_DT;
if (!holding)
{
if (distToGoal < kArriveDist || phaseClock > kWaypointTimeout)
{
holding = true;
phaseClock = 0.0f;
}
}
else if (phaseClock >= p.hold)
{
if (p.hold >= 4.0f)
{
std::printf(" hold @ (%.0f,%.0f) %.0fs -> lanes %d/%d uptime %.1f%% settle %.2f\n",
double(p.x), double(p.y), double(p.hold), CountLanes(game), NUM_AGENTS,
double(game.metrics.uptimeAvg), double(game.metrics.lastSettle));
worstHoldUptime = std::min(worstHoldUptime, game.metrics.uptimeAvg);
if (opt.probe)
{
const SolverContext ctx = game.MakeContext();
std::printf(" живых мишеней %d/%d\n", CountAliveTargets(game.targets),
NUM_TARGETS);
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = game.squad.agents[i];
const int nearest = NearestAliveTarget(a.pos, ctx, 1e9f);
const float d =
(nearest >= 0) ? Distance(a.pos, game.targets[nearest].pos) : -1.0f;
std::printf(" #%d pos(%.1f,%.1f) lane=%d t=%d ally=%d wall=%d "
"nearest=%.1f post(%.1f,%.1f) uptime=%.0f%%\n",
i, double(a.pos.x), double(a.pos.y), int(a.hasLane),
a.currentTargetId, int(a.targetBlockedByAlly),
int(a.targetBlockedByWall), double(d), double(a.postPos.x),
double(a.postPos.y), double(game.metrics.uptimePct[i]));
}
}
}
holding = false;
phaseClock = 0.0f;
wp = (wp + 1) % kRouteCount;
}
worstSettle = std::max(worstSettle, game.metrics.lastSettle);
if (game.simTime >= nextReport)
{
nextReport += 10.0f;
std::printf("%6.1f %8d %6d %6d %7.1f %6.2f %d/%d\n", double(game.simTime),
game.metrics.friendlyHits, game.metrics.shotsFired,
game.metrics.targetsKilled, double(game.metrics.uptimeAvg),
double(game.metrics.lastSettle), CountLanes(game), NUM_AGENTS);
}
}
const double avgUs = (totalSteps > 0) ? (simSeconds * 1e6 / totalSteps) : 0.0;
std::printf("---\nFRIENDLY_HITS = %d\nshots = %d kills = %d\n", game.metrics.friendlyHits,
game.metrics.shotsFired, game.metrics.targetsKilled);
std::printf("FIRE UPTIME avg = %.1f%% (по агентам:", double(game.metrics.uptimeAvg));
for (int i = 0; i < NUM_AGENTS; ++i) std::printf(" %.0f", double(game.metrics.uptimePct[i]));
std::printf(")\nхудший uptime на стоянке = %.1f%%\n", double(worstHoldUptime));
std::printf("SETTLE last = %.2f s, worst = %.2f s\n", double(game.metrics.lastSettle),
double(worstSettle));
if (game.squad.meleeCount > 0)
std::printf("MELEE: в зоне удара %.1f%% времени, взмахов %d, попаданий %d, оборвано %d\n",
double(game.metrics.meleeUptimeAvg), game.metrics.meleeSwings,
game.metrics.meleeHits, game.metrics.meleeAborts);
std::printf("макс. время в чужой линии = %.2f s, дёрганий postPos = %d\n",
double(watch.maxResidence), watch.jitterViolations);
std::printf("шаг симуляции в среднем = %.1f us (бюджет кадра 16666 us)\n", avgUs);
for (int i = 0; i < game.metrics.hitLogCount; ++i)
{
const FriendlyHitEvent& e = game.metrics.hitLog[i];
std::printf(" friendly hit t=%.2f #%d -> #%d @ (%.1f,%.1f), жертва %s\n", double(e.time),
e.shooter, e.victim, double(e.pos.x), double(e.pos.y),
e.victimAdvancing ? "ADVANCING" : "HOLDING");
}
return game.metrics.friendlyHits == 0 ? 0 : 1;
}
// -----------------------------------------------------------------------------
// --accept: сценарии из раздела 11
// -----------------------------------------------------------------------------
int RunAccept()
{
bool allPass = true;
Watcher watch; // общий: коммиты postPos по всем сценариям
Watcher arenaWatch; // открытая арена: там и меряется критерий 11.4
// Критерии 11.1 / 11.6 / 11.7 / 11.8 описывают ПРАВИЛА СТРЕЛЬБЫ и меряются
// на отряде из пяти стрелков — так же, как их формулировал спек. Клинок
// проверяется отдельно (блок M7 ниже) и в трёхминутном прогоне 11.2, где
// состав отряда обычный, боевой.
const int kSpecSquadMelee = 0;
const int kSpecSquadPistol = 0;
// === 11.1 + 11.8: отряд в куче на открытой арене, игрок останавливается ====
{
Game game;
game.squad.meleeCount = kSpecSquadMelee;
game.squad.pistolCount = kSpecSquadPistol;
game.Init();
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
game.PlaceSquad({28.0f, 23.5f}, true); // кучка из 5 мишеней в радиусе
// 0.4 с движения, чтобы отряд был в ADVANCING, потом игрок отпускает клавиши
for (int i = 0; i < int(0.4f / FIXED_DT); ++i)
{
game.in = InputState{};
game.in.moveScreen = {0.0f, 1.0f};
game.Step(FIXED_DT);
}
float clock = 0.0f;
float settle4 = -1.0f;
for (int i = 0; i < int(25.0f / FIXED_DT); ++i)
{
game.in = InputState{};
game.Step(FIXED_DT);
watch.Observe(game, FIXED_DT);
arenaWatch.Observe(game, FIXED_DT);
clock += FIXED_DT;
if (settle4 < 0.0f && CountLanes(game) >= 4) settle4 = clock;
}
const bool p1 = (settle4 >= 0.0f && settle4 <= 1.5f);
const bool p8 = (game.metrics.uptimeAvg >= 80.0f);
allPass = allPass && p1 && p8;
PrintVerdict("11.1 >=4/5 линий за <=1.5 с после остановки", p1,
Fmt("settle=%.2f с, линий сейчас %d/5", double(settle4),
CountLanes(game)));
PrintVerdict("11.8 FIRE UPTIME в стоячем бою >= 80%", p8,
Fmt("uptime=%.1f%%", double(game.metrics.uptimeAvg)));
}
// === 11.6: узкий проход в 2 тайла =========================================
// Коридор: стена y=13..15, проём x=30..31 -> движение вдоль -y, поперёк = x.
{
Game game;
game.squad.meleeCount = kSpecSquadMelee;
game.squad.pistolCount = kSpecSquadPistol;
game.Init();
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
game.PlaceSquad({31.0f, 19.0f}, false);
Watcher corridorWatch;
double lateralSum = 0.0; // усреднённый разброс поперёк/вдоль движения
double longitudinalSum = 0.0;
int maxInside = 0;
// Считаются только выстрелы ВПЕРЁД, по ходу движения: именно там между
// стрелком и целью может оказаться свой. Выстрел назад, в оставленную
// позади комнату, к правилу «не стрелять сквозь своего» отношения не
// имеет — впереди по такой линии никого из отряда нет по построению.
int rearShotsInside = 0; // вперёд стреляет НЕ передний — вот это важно
int frontShotsInside = 0;
int backwardShots = 0; // для отчёта: сколько огня ушло назад
int throughAllyShots = 0; // выстрелов, прошедших через тело союзника
int samples = 0;
// Две фазы: сначала входим в проход, потом ВСТАЁМ и меряем.
//
// Мерить на ходу нельзя: пока игрок тянет курсор, тот всегда обгоняет
// отряд (anchorSpeed 3.3 против moveSpeed 1.75), натяжение поводка
// упирается в упор и включается спринт, а в спринте отряд по замыслу не
// стреляет вовсе (Game::FireWeapons). Критерий 11.6 проверял бы пустоту.
// Боевая ситуация в проходе — это именно «вошли и работаем»: тогда
// передний держит линию, а задние упираются в его спину.
// Меряем ВСЮ проводку через горловину, а не только стоянку в ней.
// Проход глубиной три тайла, а бойцов пятеро — колонна физически длиннее
// самого прохода и целиком внутрь него не помещается никогда. Поэтому
// область замера это горловина ПЛЮС подходы с обеих сторон: именно на
// этом отрезке видно, вытянулся отряд в нитку или пошёл шеренгой.
//
// Порог входа в шеренгу узкий по построению: сам проём шириной два тайла.
const int totalSteps = int(26.0f / FIXED_DT);
const int advanceSteps = int(16.0f / FIXED_DT);
for (int i = 0; i < totalSteps; ++i)
{
game.in = InputState{};
// Ведём отряд сквозь проход и оставляем работать по северной кучке.
if (i < advanceSteps) game.in.moveScreen = MoveInputToward(game, {31.0f, 9.0f});
game.Step(FIXED_DT);
watch.Observe(game, FIXED_DT);
corridorWatch.Observe(game, FIXED_DT);
// Кто сейчас в створе прохода (горловина + подходы).
int inside[NUM_AGENTS];
int insideCount = 0;
for (int k = 0; k < NUM_AGENTS; ++k)
{
const Vec2 p = game.squad.agents[k].pos;
if (p.y > 9.0f && p.y < 19.0f && p.x > 28.0f && p.x < 34.0f)
inside[insideCount++] = k;
}
if (insideCount < 2) continue;
++samples;
maxInside = std::max(maxInside, insideCount);
float xMin = 1e9f, xMax = -1e9f, yMin = 1e9f, yMax = -1e9f;
float frontY = 1e9f;
for (int n = 0; n < insideCount; ++n)
{
const Vec2 p = game.squad.agents[inside[n]].pos;
xMin = std::min(xMin, p.x); xMax = std::max(xMax, p.x);
yMin = std::min(yMin, p.y); yMax = std::max(yMax, p.y);
frontY = std::min(frontY, p.y);
}
// Колонна = вытянуты вдоль движения сильнее, чем поперёк.
lateralSum += double(xMax - xMin);
longitudinalSum += double(yMax - yMin);
for (int n = 0; n < insideCount; ++n)
{
const Agent& a = game.squad.agents[inside[n]];
if (!a.fired) continue;
const Vec2 tp = (a.currentTargetId >= 0) ? game.targets[a.currentTargetId].pos : Vec2{};
// НАСТОЯЩАЯ проверка правила, независимая от реализации: берём
// отрезок реально сделанного выстрела и заново, своей геометрией,
// смотрим, не задевает ли он тело союзника. Порог физический
// (тело + полуширина трассы), без запаса safetyMargin: выстрел
// впритирку, но мимо тела, правило не нарушает.
const Vec2 muzzle = a.MuzzleTowards(tp);
for (int k = 0; k < NUM_AGENTS; ++k)
{
if (k == inside[n]) continue;
const Agent& ally = game.squad.agents[k];
if (DistancePointToSegment(ally.pos, muzzle, tp) <
ally.bodyRadius + g_tune.laneHalfWidth)
++throughAllyShots;
}
// Разбивка ниже — только для отчёта. Задний, стреляющий ПОД УГЛОМ
// мимо переднего, ничего не нарушает: проход шириной 2 тайла даёт
// законные диагональные линии, и это подтверждается проверкой выше.
const bool forward = Dot(tp - a.pos, game.squad.forward) > 0.0f;
const bool rear = a.pos.y > frontY + 0.5f;
if (!forward) { ++backwardShots; continue; }
if (rear) ++rearShotsInside;
else ++frontShotsInside;
}
}
const double lateralAvg = (samples > 0) ? lateralSum / samples : 0.0;
const double longAvg = (samples > 0) ? longitudinalSum / samples : 0.0;
// Что здесь проверяется на самом деле.
//
// «Задние не стреляют» — это СЛЕДСТВИЕ правила «не стрелять сквозь
// своего», а не отдельное правило. В проходе шириной 2 тайла у заднего
// бойца есть законные диагональные линии мимо переднего, и считать их
// нарушением — значит требовать от отряда быть глупее, чем он есть.
// Поэтому считаем не «кто стрелял», а инвариант: НИ ОДИН выстрел в
// проходе не прошёл через тело союзника (перепроверено своей геометрией
// по факту выстрела) и ни одного попадания по своим.
//
// Плюс форма: отряд обязан вытянуться в колонну, а не идти шеренгой.
// Пороги с запасом: строгое неравенство на маленьких числах — монетка,
// а не тест.
const bool column = longAvg > lateralAvg * 1.5;
const bool clean = (throughAllyShots == 0) && (game.metrics.friendlyHits == 0);
const bool enough = (frontShotsInside + rearShotsInside) >= 4;
const bool p6 = (samples > 0) && (maxInside >= 2) && column && clean && enough;
allPass = allPass && p6;
PrintVerdict("11.6 в проходе колонна, огонь не идёт сквозь своих", p6,
Fmt("до %d агентов в проходе, поперёк %.2f / вдоль %.2f тайла, "
"выстрелов вперёд передний/хвост %d/%d (назад %d), "
"сквозь своего %d, FRIENDLY_HITS=%d",
maxInside, lateralAvg, longAvg, frontShotsInside, rearShotsInside,
backwardShots, throughAllyShots, game.metrics.friendlyHits));
}
// === 11.7: ручной режим, назначенная цель =================================
{
Game game;
game.squad.meleeCount = kSpecSquadMelee;
game.squad.pistolCount = kSpecSquadPistol;
game.Init();
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
game.PlaceSquad({28.0f, 23.5f}, true);
// Включаем ручной режим и наводим вектор прицела на кучку.
InputState toggle;
toggle.toggleAimMode = true;
game.in = toggle;
game.Step(FIXED_DT);
int stepsWithAssigned = 0;
for (int i = 0; i < int(30.0f / FIXED_DT); ++i)
{
game.in = InputState{};
// Медленно крутим прицел — Tab каждые 5 секунд.
game.in.aimTurn = (i < int(3.0f / FIXED_DT)) ? 1.0f : 0.0f;
game.in.cycleTarget = (i % int(5.0f / FIXED_DT)) == 0;
game.Step(FIXED_DT);
watch.Observe(game, FIXED_DT);
if (game.aim.assignedTargetId >= 0) ++stepsWithAssigned;
}
const bool p7 = (game.metrics.friendlyHits == 0) && (stepsWithAssigned > 0);
allPass = allPass && p7;
PrintVerdict("11.7 ручной режим не стреляет сквозь своих", p7,
Fmt("FRIENDLY_HITS=%d, назначенная цель была %.0f%% времени",
game.metrics.friendlyHits,
100.0 * double(stepsWithAssigned) / (30.0 / FIXED_DT)));
}
// === M7: ближний бой ======================================================
// Смешанный отряд у кучки из пяти мишеней. Проверяется ровно то, что
// требовалось от механики: клинок доходит и бьёт, никого из своих при этом
// не задевает, и каждый класс живёт в СВОЁМ круге вокруг диска.
{
Game game;
game.Init(); // состав по умолчанию: 2 клинка
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
game.PlaceSquad({28.0f, 23.5f}, true);
float maxMeleeFromAnchor = 0.0f;
float maxRangedFromAnchor = 0.0f;
for (int i = 0; i < int(30.0f / FIXED_DT); ++i)
{
game.in = InputState{};
game.Step(FIXED_DT);
watch.Observe(game, FIXED_DT);
for (int k = 0; k < NUM_AGENTS; ++k)
{
const Agent& a = game.squad.agents[k];
const float d = Distance(a.pos, game.squad.anchor);
if (a.IsMelee()) maxMeleeFromAnchor = std::max(maxMeleeFromAnchor, d);
else maxRangedFromAnchor = std::max(maxRangedFromAnchor, d);
}
}
const Metrics& m = game.metrics;
// Порог круга с запасом на инерцию: цель может быть у самой границы,
// и боец имеет право дотянуться до неё, а не тормозить по линейке.
const float meleeLimit = g_tune.meleeCohesionRadius + 1.2f;
const float rangedLimit = g_tune.cohesionRadius + 1.6f;
const bool pM1 = (m.meleeHits > 0) && (m.meleeSwings > 0);
const bool pM2 = (m.friendlyHits == 0);
const bool pM3 = (maxMeleeFromAnchor <= meleeLimit) &&
(maxRangedFromAnchor <= rangedLimit) &&
(maxRangedFromAnchor < maxMeleeFromAnchor);
allPass = allPass && pM1 && pM2 && pM3;
PrintVerdict("M7.1 клинок доходит до цели и наносит урон", pM1,
Fmt("взмахов %d, попаданий %d, оборвано своими %d, убито %d; "
"связка %s=%d %s=%d %s=%d",
m.meleeSwings, m.meleeHits, m.meleeAborts, m.targetsKilled,
MELEE_COMBO[0].name, m.meleeComboSwings[0], MELEE_COMBO[1].name,
m.meleeComboSwings[1], MELEE_COMBO[2].name, m.meleeComboSwings[2]));
const bool pM4 = m.meleeMaxHitExcessDeg <= 12.0f;
allPass = allPass && pM4;
PrintVerdict("M7.4 лезвие не достаёт за пределы своей дуги", pM4,
Fmt("максимум за сектором %.1f град (лимит 12)",
double(m.meleeMaxHitExcessDeg)));
PrintVerdict("M7.2 ближний бой не задевает своих", pM2,
Fmt("FRIENDLY_HITS=%d при %d взмахах", m.friendlyHits, m.meleeSwings));
PrintVerdict("M7.3 у каждого класса свой круг вокруг диска", pM3,
Fmt("стрелки до %.2f (лимит %.2f), клинки до %.2f (лимит %.2f)",
double(maxRangedFromAnchor), double(rangedLimit),
double(maxMeleeFromAnchor), double(meleeLimit)));
}
// === 11.2: три минуты непрерывного боя во всех режимах =====================
{
HarnessOptions opt;
opt.seconds = 180.0f; // состав боевой: смешанный отряд, как в игре
std::printf("--- прогон 180 с по маршруту (критерий 11.2) ---\n");
const int rc = RunHeadless(opt);
const bool p2 = (rc == 0);
allPass = allPass && p2;
PrintVerdict("11.2 FRIENDLY_HITS = 0 за 3 минуты", p2, p2 ? "0 попаданий" : "см. журнал");
}
// === 11.4 + 11.5 ==========================================================
// 11.4 меряется на открытой арене: в проходе 2 тайла разойтись физически
// некуда, и это ровно тот геометрический тест-кейс, о котором говорит спек.
const bool p4 = arenaWatch.maxResidence <= 0.4f;
const bool p5 = watch.jitterViolations == 0;
allPass = allPass && p4 && p5;
PrintVerdict("11.4 агент уходит с чужой линии за ~0.3 с", p4,
Fmt("на арене максимум %.2f с (по всем сценариям %.2f с)",
double(arenaWatch.maxResidence), double(watch.maxResidence)));
PrintVerdict("11.5 нет дёрганья postPos чаще commitTime", p5,
Fmt("коммитов %d, нарушений %d, минимальный интервал %.2f с", watch.commits,
watch.jitterViolations,
double(watch.minCommitInterval > 1e8f ? 0.0f : watch.minCommitInterval)));
std::printf("\nИТОГ: %s\n", allPass ? "все проверяемые критерии PASS" : "есть FAIL");
return allPass ? 0 : 1;
}

26
src/debug/harness.h Normal file
View File

@ -0,0 +1,26 @@
#pragma once
// -----------------------------------------------------------------------------
// Прогоны симуляции без окна. Симуляция детерминирована и ничего не знает про
// рендер, поэтому критерии приёмки (спек 11) можно мерить численно, а не на глаз.
//
// --headless <сек> маршрут по путевым точкам + метрики
// --accept сценарии из раздела 11 с вердиктом PASS/FAIL
// -----------------------------------------------------------------------------
#include "tuning.h"
struct HarnessOptions
{
float seconds = 60.0f;
bool solver = true; // --no-solver
bool laneAvoid = true; // --no-laneavoid
bool probe = false; // --probe: подробности по агентам на стоянках
// --melee N: сколько слотов держат клинок. Критерии стрельбы из спека 11
// меряются на чистом отряде стрелков — иначе меняется смысл самих метрик.
int meleeCount = DEFAULT_MELEE_COUNT;
// --pistol N: сколько ПОСЛЕДНИХ слотов носят пистолет вместо карабина.
// В сценариях спека тоже 0: дальность и разброс там должны быть штатными.
int pistolCount = DEFAULT_PISTOL_COUNT;
};
int RunHeadless(const HarnessOptions& opt);
int RunAccept();

103
src/debug/metrics.cpp Normal file
View File

@ -0,0 +1,103 @@
#include "debug/metrics.h"
void Metrics::LogFriendlyHit(int shooter, int victim, Vec2 pos, bool victimAdvancing)
{
++friendlyHits;
if (hitLogCount < HIT_LOG_MAX)
hitLog[hitLogCount++] = FriendlyHitEvent{clock, shooter, victim, pos, victimAdvancing};
}
void Metrics::Reset()
{
friendlyHits = 0;
hitLogCount = 0;
clock = 0.0f;
targetsKilled = 0;
shotsFired = 0;
meleeSwings = 0;
meleeHits = 0;
meleeAborts = 0;
for (int& v : meleeComboSwings) v = 0;
meleeMaxHitExcessDeg = 0.0f;
reloads = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
laneCount[i] = 0;
uptimePct[i] = 0.0f;
for (int k = 0; k < UPTIME_SAMPLES; ++k) ring[i][k] = 0;
}
ringPos = 0;
ringFill = 0;
uptimeAvg = 0.0f;
meleeUptimeAvg = 0.0f;
settleArmed = true; // старт — отряд уже стоит, замеряем первое расхождение
settleClock = 0.0f;
lastSettle = -1.0f;
prevState = AgentState::HOLDING;
simMs = 0.0f;
renderMs = 0.0f;
}
void Metrics::Sample(const Squad& squad, float dt)
{
clock += dt;
// --- FIRE UPTIME (скользящее окно) ---
const int window = std::min(UPTIME_SAMPLES,
std::max(1, int(g_tune.uptimeWindow / FIXED_DT)));
if (ringPos >= window) ringPos = 0;
// «В бою» для стрелка — валидная линия огня, для клинка — цель в зоне удара.
// Считаются они одинаково, а усредняются раздельно: смешивать доли времени
// двух разных занятий в одно число бессмысленно.
int readyNow = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
const bool engaged = a.IsMelee() ? a.melee.inReach : a.hasLane;
const unsigned char v = engaged ? 1u : 0u;
laneCount[i] -= ring[i][ringPos];
ring[i][ringPos] = v;
laneCount[i] += v;
// Для SETTLE TIME клинку достаточно ИМЕТЬ цель: он к ней ещё идёт,
// но отряд уже разобрал обстановку, а именно это метрика и меряет.
if (a.IsMelee() ? (a.melee.targetId >= 0) : a.hasLane) ++readyNow;
}
ringPos = (ringPos + 1) % window;
ringFill = std::min(ringFill + 1, window);
float rangedSum = 0.0f;
float meleeSum = 0.0f;
for (int i = 0; i < NUM_AGENTS; ++i)
{
uptimePct[i] = (ringFill > 0) ? (100.0f * float(laneCount[i]) / float(ringFill)) : 0.0f;
if (squad.agents[i].IsMelee()) meleeSum += uptimePct[i];
else rangedSum += uptimePct[i];
}
const int ranged = squad.RangedCount();
uptimeAvg = (ranged > 0) ? rangedSum / float(ranged) : 0.0f;
meleeUptimeAvg = (squad.meleeCount > 0) ? meleeSum / float(squad.meleeCount) : 0.0f;
// --- SETTLE TIME ---
if (squad.state == AgentState::HOLDING && prevState == AgentState::ADVANCING)
{
settleArmed = true;
settleClock = 0.0f;
}
if (squad.state == AgentState::ADVANCING) settleArmed = false;
if (settleArmed)
{
settleClock += dt;
if (float(readyNow) >= g_tune.settleQuorum)
{
lastSettle = settleClock;
settleArmed = false;
}
}
prevState = squad.state;
}

78
src/debug/metrics.h Normal file
View File

@ -0,0 +1,78 @@
#pragma once
// -----------------------------------------------------------------------------
// Метрики корректности (спек 7):
// FRIENDLY_HITS — накопительный счётчик попаданий по своим (главная метрика);
// FIRE UPTIME — доля времени с валидной линией за последние 10 сек;
// SETTLE TIME — от перехода в HOLDING до момента, когда >=4 агента в бою.
//
// С появлением ближнего боя два уточнения, иначе цифры врут:
// * FIRE UPTIME усредняется ТОЛЬКО по стрелкам. У бойца с клинком линии огня
// не бывает по определению, и его ноль занижал бы метрику стрельбы;
// * «в бою» (для SETTLE TIME) = стрелок при линии ИЛИ клинок при цели.
// Отдельно считается MELEE UPTIME — доля времени в зоне удара.
// -----------------------------------------------------------------------------
#include "sim/squad.h"
constexpr int UPTIME_SAMPLES = 600; // 10 сек при 60 Гц
// Журнал попаданий по своим: без него FRIENDLY_HITS != 0 нечем объяснить.
struct FriendlyHitEvent
{
float time = 0.0f;
int shooter = -1;
int victim = -1;
Vec2 pos{};
bool victimAdvancing = false;
};
constexpr int HIT_LOG_MAX = 16;
struct Metrics
{
int friendlyHits = 0;
int targetsKilled = 0;
int shotsFired = 0;
// ближний бой
int meleeSwings = 0; // взмахов начато
int meleeHits = 0; // попаданий лезвием
int meleeAborts = 0; // взмахов оборвано из-за своего в дуге
// Сколько взмахов пришлось на каждый удар связки. Если серия не
// прокручивается, здесь будет ноль во всех ячейках кроме первой.
int meleeComboSwings[MELEE_COMBO_STEPS] = {};
// На сколько градусов попадание лезвия вышло за РАЗРЕШЁННЫЙ сектор этого
// взмаха (полусектор + полуширина полосы). Должно быть ~0: если лезвие
// достаёт за спину, здесь вырастет десятки градусов. Ровно этим и выглядел
// баг «бьёт на 360».
float meleeMaxHitExcessDeg = 0.0f;
int reloads = 0; // сколько раз стрелки уходили в перезарядку
FriendlyHitEvent hitLog[HIT_LOG_MAX];
int hitLogCount = 0;
float clock = 0.0f; // время симуляции, для журнала
void LogFriendlyHit(int shooter, int victim, Vec2 pos, bool victimAdvancing);
// FIRE UPTIME: кольцевой буфер + инкрементальные счётчики
unsigned char ring[NUM_AGENTS][UPTIME_SAMPLES] = {};
int ringPos = 0;
int ringFill = 0;
int laneCount[NUM_AGENTS] = {};
float uptimePct[NUM_AGENTS] = {};
float uptimeAvg = 0.0f; // только по стрелкам
float meleeUptimeAvg = 0.0f; // только по клинкам: доля времени в зоне удара
// SETTLE TIME
bool settleArmed = false;
float settleClock = 0.0f;
float lastSettle = -1.0f;
// производительность (заполняется главным циклом)
float simMs = 0.0f;
float renderMs = 0.0f;
AgentState prevState = AgentState::HOLDING;
void Reset();
void Sample(const Squad& squad, float dt);
};

307
src/debug/overlay.cpp Normal file
View File

@ -0,0 +1,307 @@
#include "debug/overlay.h"
#include <raylib.h>
#include "game.h"
#include "render/view.h"
namespace
{
// Мир -> координаты окна (кадр уже растянут в WINDOW_SCALE раз).
inline Vector2 ToWindow(const View& v, Vec2 world)
{
const Vec2 s = iso::WorldToScreen(world);
return Vector2{(s.x + float(v.scene.ox)) * float(WINDOW_SCALE),
(s.y + float(v.scene.oy)) * float(WINDOW_SCALE)};
}
void Ring(const View& g, Vec2 center, float radiusWorld, Color c, int segments = 48)
{
Vector2 prev{};
for (int i = 0; i <= segments; ++i)
{
const float a = float(i) * (TWO_PI_F / float(segments));
const Vector2 p = ToWindow(g, center + FromAngle(a) * radiusWorld);
if (i > 0) DrawLineV(prev, p, c);
prev = p;
}
}
const char* StateName(AgentState s)
{
return (s == AgentState::ADVANCING) ? "ADVANCING" : "HOLDING";
}
const char* MeleePhase(MeleeState s)
{
switch (s)
{
case MeleeState::WINDUP: return "wind";
case MeleeState::STRIKE: return "HIT";
case MeleeState::RECOVER: return "rec";
default: return "-";
}
}
Color ScaleAlpha(Color c, float k)
{
c.a = (unsigned char)Clampf(float(c.a) * k, 0.0f, 255.0f);
return c;
}
} // namespace
void Overlay::HandleKeys()
{
if (IsKeyPressed(KEY_F1)) show = !show;
if (IsKeyPressed(KEY_F2)) showLanes = !showLanes;
if (IsKeyPressed(KEY_F3)) showCandidates = !showCandidates;
if (IsKeyPressed(KEY_F4)) showMetrics = !showMetrics;
if (IsKeyPressed(KEY_LEFT_BRACKET)) tuneIndex = (tuneIndex + TUNABLE_COUNT - 1) % TUNABLE_COUNT;
if (IsKeyPressed(KEY_RIGHT_BRACKET)) tuneIndex = (tuneIndex + 1) % TUNABLE_COUNT;
const TunableParam& p = TUNABLES[tuneIndex];
float& value = g_tune.*(p.member);
if (IsKeyPressed(KEY_MINUS) || IsKeyPressedRepeat(KEY_MINUS))
value = Clampf(value - p.step, p.minV, p.maxV);
if (IsKeyPressed(KEY_EQUAL) || IsKeyPressedRepeat(KEY_EQUAL))
value = Clampf(value + p.step, p.minV, p.maxV);
}
void Overlay::Draw(const Game& game, const View& view) const
{
if (!show) return;
const Squad& squad = game.squad;
// --- якорь, круги когезии, слоты строя ---
Ring(view, squad.anchor, g_tune.cohesionRadius, Color{60, 90, 120, 160});
// Свой, больший круг бойцов ближнего боя — их отпускают дальше.
if (squad.meleeCount > 0)
Ring(view, squad.anchor, g_tune.meleeCohesionRadius, Color{150, 100, 60, 110});
// Поводок курсора, обе границы упругой модели: сплошной круг — мягкая
// граница (дальше курсор тяжелеет), тусклый внешний — жёсткий предел.
// По зазору между ними видно натяжение, то есть скоро ли отряд побежит.
Ring(view, squad.Centroid(), g_tune.anchorLeash,
squad.sprinting ? Color{255, 160, 70, 130} : Color{70, 110, 150, 110});
Ring(view, squad.Centroid(), std::max(g_tune.anchorLeash + 0.2f, g_tune.anchorLeashMax),
Color{90, 70, 110, 70});
DrawCircleV(ToWindow(view, squad.anchor), 4.0f,
squad.sprinting ? Color{255, 190, 120, 230} : Color{120, 200, 255, 220});
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Vector2 p = ToWindow(view, squad.SlotWorld(i));
DrawCircleLinesV(p, 5.0f, Color{80, 120, 160, 140});
}
// --- сектор ближнего боя ---
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
if (!a.IsMelee()) continue;
const MeleeWeapon& m = a.melee;
Color c;
switch (m.state)
{
case MeleeState::WINDUP: c = Color{255, 200, 80, 220}; break; // замах
case MeleeState::STRIKE: c = Color{255, 255, 255, 240}; break; // удар
case MeleeState::RECOVER: c = Color{120, 120, 140, 160}; break;
default:
// В покое цвет говорит, почему не бьём: жёлтый — свой в дуге.
c = m.arcBlockedByAlly ? Color{240, 210, 60, 180}
: Color{90, 150, 200, 110};
break;
}
Ring(view, a.pos, m.reach, ScaleAlpha(c, 0.55f), 24);
const float axis = m.Committed() ? m.dir : a.facing;
const float half = m.HalfArc();
for (int s = -1; s <= 1; s += 2)
{
const Vec2 e = a.pos + FromAngle(axis + half * float(s)) * m.reach;
DrawLineV(ToWindow(view, a.pos), ToWindow(view, e), c);
}
if (m.state == MeleeState::STRIKE)
{
const Vec2 blade = a.pos + FromAngle(m.BladeAngleAt(m.Progress())) * m.reach;
DrawLineEx(ToWindow(view, a.pos), ToWindow(view, blade), 2.0f, c);
}
}
// --- линии огня (F2) ---
if (showLanes)
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
if (a.currentTargetId < 0 && !a.targetBlockedByAlly && !a.targetBlockedByWall) continue;
Color c;
if (a.hasLane) c = Color{60, 230, 90, 220}; // чистая, стреляет
else if (a.targetBlockedByWall) c = Color{230, 70, 60, 200}; // перекрыта стеной
else c = Color{240, 210, 60, 200}; // перекрыта своим
const Vec2 end = a.laneEnd;
DrawLineEx(ToWindow(view, a.MuzzleTowards(end)), ToWindow(view, end), 2.0f, c);
}
}
// --- кандидаты решателя (F3) ---
if (showCandidates)
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
if (!game.lastSolveValid[i]) continue;
const SolveResult& r = game.lastSolve[i];
float lo = 1e9f, hi = -1e9f;
for (int k = 0; k < r.count; ++k)
{
if (!r.valid[k]) continue;
lo = std::min(lo, r.scores[k]);
hi = std::max(hi, r.scores[k]);
}
const float span = std::max(1e-3f, hi - lo);
for (int k = 0; k < r.count; ++k)
{
const Vector2 p = ToWindow(view, r.candidates[k]);
if (!r.valid[k])
{
DrawCircleV(p, 2.0f, Color{80, 40, 40, 140}); // отбракован
continue;
}
const float t = (r.scores[k] - lo) / span; // синий -> белый
const unsigned char v = (unsigned char)(60 + 195 * t);
DrawCircleV(p, 3.0f, Color{v, v, 255, 200});
}
if (r.bestIndex > 0)
DrawCircleLinesV(ToWindow(view, r.candidates[r.bestIndex]), 8.0f,
Color{255, 255, 255, 230});
}
}
// --- диски обзора (F6) ---
if (view.showSight)
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
Ring(view, a.pos, g_tune.sightRadius, Color{60, 130, 90, 70});
Ring(view, a.pos, g_tune.sightPersonalRadius, Color{90, 170, 120, 110});
// Границы конуса взгляда.
const float half = g_tune.sightConeDeg * 0.5f * DEG2RAD_F;
for (int sgn = -1; sgn <= 1; sgn += 2)
{
const Vec2 e = a.pos + FromAngle(a.facing + half * float(sgn)) * g_tune.sightRadius;
DrawLineV(ToWindow(view, a.pos), ToWindow(view, e), Color{90, 200, 140, 110});
}
}
}
// --- подписи над агентами ---
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
const Vector2 p = ToWindow(view, a.pos);
if (a.IsMelee())
DrawText(TextFormat("%dM %c t%d %s", i, a.state == AgentState::ADVANCING ? 'A' : 'H',
a.currentTargetId, MeleePhase(a.melee.state)),
int(p.x) - 18, int(p.y) - 62, 12, Color{255, 210, 170, 210});
else
{
// Патроны и перезарядка — то, чего не видно по силуэту, но от чего
// напрямую зависит, почему стрелок с чистой линией молчит.
const char* tag = (a.gunId == FirearmId::PISTOL) ? "P" : "R";
const Color col = a.gun.Reloading() ? Color{255, 170, 90, 230}
: Color{220, 220, 220, 210};
if (a.gun.Reloading())
DrawText(TextFormat("%d%s %c t%d RELOAD %.1f", i, tag,
a.state == AgentState::ADVANCING ? 'A' : 'H',
a.currentTargetId, double(a.gun.reloadTimer)),
int(p.x) - 18, int(p.y) - 62, 12, col);
else
DrawText(TextFormat("%d%s %c t%d %d/%d", i, tag,
a.state == AgentState::ADVANCING ? 'A' : 'H',
a.currentTargetId, a.gun.ammo, a.gun.def.magazine),
int(p.x) - 18, int(p.y) - 62, 12, col);
}
// выбранная огневая позиция
if (a.state == AgentState::HOLDING || a.IsMelee())
DrawCircleLinesV(ToWindow(view, a.postPos), 4.0f, Color{160, 160, 255, 140});
}
// --- назначенная цель / вектор прицела (6.8) ---
if (game.aim.manual)
{
const Vec2 from = squad.anchor;
const Vec2 to = from + FromAngle(game.aim.dir) * g_tune.weaponRange;
DrawLineEx(ToWindow(view, from), ToWindow(view, to), 1.0f, Color{255, 255, 255, 90});
const float cone = g_tune.aimConeDeg * DEG2RAD_F;
for (int s = -1; s <= 1; s += 2)
{
const Vec2 e = from + FromAngle(game.aim.dir + cone * float(s)) * g_tune.weaponRange;
DrawLineEx(ToWindow(view, from), ToWindow(view, e), 1.0f, Color{255, 255, 255, 50});
}
if (game.aim.assignedTargetId >= 0)
DrawCircleLinesV(ToWindow(view, game.targets[game.aim.assignedTargetId].pos), 12.0f,
Color{255, 120, 120, 230});
}
// --- текстовая панель ---
int y = 8;
auto line = [&](const char* text, Color c = RAYWHITE) {
DrawText(text, 8, y, 18, c);
y += 20;
};
line(TextFormat("%s %s aim:%s fps %d", StateName(squad.state),
FormationName(squad.formation), game.aim.manual ? "MANUAL" : "AUTO", GetFPS()));
// Натяжение поводка: главная новая величина управления. Видно, почему отряд
// ускорился и почему перестал стрелять.
line(TextFormat("PULL %.2f %s speed %.2f form %.2fx%.2f", double(squad.urgency),
squad.sprinting ? "SPRINT" : " ", double(squad.MoveSpeed()),
double(squad.formSpacing), double(squad.formStretch)),
squad.sprinting ? Color{255, 180, 90, 255} : Color{170, 200, 230, 255});
if (showMetrics)
{
const Metrics& m = game.metrics;
line(TextFormat("FRIENDLY_HITS %d", m.friendlyHits),
m.friendlyHits == 0 ? Color{120, 240, 120, 255} : Color{255, 90, 90, 255});
line(TextFormat("FIRE UPTIME %.0f%% [%.0f %.0f %.0f %.0f %.0f]", double(m.uptimeAvg),
double(m.uptimePct[0]), double(m.uptimePct[1]), double(m.uptimePct[2]),
double(m.uptimePct[3]), double(m.uptimePct[4])));
line(TextFormat("SETTLE %.2f s shots %d kills %d reloads %d", double(m.lastSettle),
m.shotsFired, m.targetsKilled, m.reloads));
if (squad.meleeCount > 0)
line(TextFormat("MELEE reach %.0f%% swings %d hits %d aborted %d",
double(m.meleeUptimeAvg), m.meleeSwings, m.meleeHits, m.meleeAborts),
m.meleeAborts == 0 ? Color{255, 200, 150, 255} : Color{240, 210, 60, 255});
line(TextFormat("sim %.2f render %.2f ms/frame", double(m.simMs), double(m.renderMs)),
Color{180, 180, 180, 255});
// Кровь: сколько тайлов пола уже запечено. Растёт и не убывает — это
// и есть «декали навсегда», и видно, во что они обходятся по памяти.
// Только ASCII: у встроенного шрифта raylib нет кириллицы, она вылезает
// вопросительными знаками. Весь оверлей по этой причине на латинице.
line(TextFormat("BLOOD %d splats, %d tiles baked (limit %d)", view.blood.splats,
int(view.blood.stained.size()), BLOOD_TILE_BUDGET),
Color{200, 130, 130, 255});
}
const TunableParam& p = TUNABLES[tuneIndex];
line(TextFormat("[ %s ] = %.3f ( [ ] select, - = adjust )", p.name,
double(g_tune.*(p.member))),
Color{255, 210, 120, 255});
DrawText("F1 overlay F2 lanes F3 cand F4 metrics F5 horror F6 sight R reset Esc", 8,
WINDOW_H - 24, 16, Color{160, 160, 160, 200});
}

20
src/debug/overlay.h Normal file
View File

@ -0,0 +1,20 @@
#pragma once
// -----------------------------------------------------------------------------
// Debug-оверлей (спек 7). Рисуется примитивами raylib ПОВЕРХ уже
// отмасштабированного кадра — это единственное исключение из правила
// «вся сцена софтверная», и оно dev-only (F1 выключает).
// -----------------------------------------------------------------------------
struct Game;
struct View;
struct Overlay
{
bool show = true; // F1
bool showLanes = true; // F2
bool showCandidates = false; // F3
bool showMetrics = true; // F4
int tuneIndex = 0; // [ / ]
void HandleKeys();
void Draw(const Game& game, const View& view) const;
};

231
src/ecs/ecs.h Normal file
View File

@ -0,0 +1,231 @@
#pragma once
// -----------------------------------------------------------------------------
// Минимальная ECS: сущность — это идентификатор, данные лежат в типизированных
// плотных пулах, системы — свободные функции над пулами.
//
// Зачем она здесь, если прототип и без неё работал:
// * оружие стало отдельной СУЩНОСТЬЮ со своим набором статов (пистолет,
// карабин, клинок). Боец ссылается на неё, а не носит числа в себе
// значит ствол можно подменить, не трогая ни бойца, ни правила боя;
// * мишени, пули и бойцы перестали быть тремя разными контейнерами в Game
// и стали тремя пулами одного мира. Добавить четвёртый вид сущности —
// это новый компонент, а не новое поле в Game и новый цикл в Step.
//
// Чего здесь НЕТ и почему:
// * архетипов и запросов вида View<A, B, C>. Сущностей в прототипе десятки,
// а не десятки тысяч; выигрыш от архетипов ушёл бы в сложность, а цена
// промаха кеша на 5 агентах равна нулю;
// * реакции на изменение компонентов, планировщика систем, рефлексии.
//
// Гранулярность компонентов сознательно КРУПНАЯ (Agent, Target, Bullet целиком,
// а не Position+Velocity+Body по отдельности). Причина в docs/12-ecs.md:
// правила боя из спека читают состояние агента целиком и в одном порядке,
// и дробление их только запутало бы, не дав ни строчки полезного обобщения.
//
// Детерминизм: удаление — swap-remove, порядок пулов зависит только от истории
// вызовов. Одинаковая последовательность операций даёт одинаковый мир, поэтому
// --headless по-прежнему воспроизводим.
// -----------------------------------------------------------------------------
#include <cstdint>
#include <memory>
#include <vector>
namespace ecs
{
// Идентификатор: младшие 24 бита — индекс слота, старшие 8 — поколение.
// Поколение отличает «ту же ячейку, но уже другую сущность» от живой ссылки.
using Entity = uint32_t;
constexpr Entity NO_ENTITY = 0xFFFFFFFFu;
constexpr uint32_t ENTITY_INDEX_BITS = 24;
constexpr uint32_t ENTITY_INDEX_MASK = (1u << ENTITY_INDEX_BITS) - 1u;
inline uint32_t IndexOf(Entity e) { return e & ENTITY_INDEX_MASK; }
inline uint32_t GenOf(Entity e) { return e >> ENTITY_INDEX_BITS; }
inline Entity MakeEntity(uint32_t index, uint32_t gen)
{
return (index & ENTITY_INDEX_MASK) | (gen << ENTITY_INDEX_BITS);
}
// --- пулы --------------------------------------------------------------------
struct IPool
{
virtual ~IPool() = default;
virtual void Remove(Entity e) = 0;
virtual void Clear() = 0;
};
// Плотный массив компонентов + разрежённая таблица «индекс сущности -> место».
// data остаётся сплошным, поэтому его можно отдавать наружу как обычный массив
// (именно так SolverContext получает Agent* и ничего не знает про ECS).
template <class T>
struct Pool final : IPool
{
std::vector<T> data;
std::vector<Entity> owner; // кому принадлежит data[i]
std::vector<int32_t> sparse; // индекс сущности -> позиция в data, -1 = нет
bool Has(Entity e) const
{
const uint32_t i = IndexOf(e);
return i < sparse.size() && sparse[i] >= 0;
}
T* Find(Entity e)
{
const uint32_t i = IndexOf(e);
if (i >= sparse.size() || sparse[i] < 0) return nullptr;
return &data[size_t(sparse[i])];
}
const T* Find(Entity e) const
{
const uint32_t i = IndexOf(e);
if (i >= sparse.size() || sparse[i] < 0) return nullptr;
return &data[size_t(sparse[i])];
}
T& Add(Entity e, const T& value)
{
const uint32_t i = IndexOf(e);
if (i >= sparse.size()) sparse.resize(size_t(i) + 1, -1);
if (sparse[i] >= 0)
{
data[size_t(sparse[i])] = value;
return data[size_t(sparse[i])];
}
sparse[i] = int32_t(data.size());
data.push_back(value);
owner.push_back(e);
return data.back();
}
void Remove(Entity e) override
{
const uint32_t i = IndexOf(e);
if (i >= sparse.size() || sparse[i] < 0) return;
const size_t slot = size_t(sparse[i]);
const size_t last = data.size() - 1;
if (slot != last)
{
data[slot] = data[last];
owner[slot] = owner[last];
sparse[IndexOf(owner[slot])] = int32_t(slot);
}
data.pop_back();
owner.pop_back();
sparse[i] = -1;
}
void Clear() override
{
data.clear();
owner.clear();
for (int32_t& s : sparse) s = -1;
}
int Count() const { return int(data.size()); }
};
// --- идентификаторы типов ----------------------------------------------------
inline uint32_t NextTypeId()
{
static uint32_t next = 0;
return next++;
}
template <class T>
inline uint32_t TypeId()
{
static const uint32_t id = NextTypeId();
return id;
}
// --- мир ---------------------------------------------------------------------
struct World
{
// Создание/уничтожение сущностей.
Entity Create()
{
if (!freeSlots.empty())
{
const uint32_t i = freeSlots.back();
freeSlots.pop_back();
alive[i] = 1;
return MakeEntity(i, generation[i]);
}
const uint32_t i = uint32_t(alive.size());
alive.push_back(1);
generation.push_back(0);
return MakeEntity(i, 0);
}
bool Alive(Entity e) const
{
const uint32_t i = IndexOf(e);
return i < alive.size() && alive[i] != 0 && generation[i] == GenOf(e);
}
void Destroy(Entity e)
{
if (!Alive(e)) return;
const uint32_t i = IndexOf(e);
for (auto& p : pools)
if (p) p->Remove(e);
alive[i] = 0;
generation[i] = uint8_t((generation[i] + 1u) & 0xFFu);
freeSlots.push_back(i);
}
// Пул компонента. Создаётся при первом обращении и живёт до смерти мира:
// Clear() чистит СОДЕРЖИМОЕ, но не сами пулы, поэтому ссылки на data
// (например Game::targets) переживают Reset уровня.
template <class T>
Pool<T>& Store()
{
const uint32_t id = TypeId<T>();
if (id >= pools.size()) pools.resize(size_t(id) + 1);
if (!pools[id]) pools[id] = std::make_unique<Pool<T>>();
return *static_cast<Pool<T>*>(pools[id].get());
}
template <class T>
const Pool<T>& Store() const
{
return const_cast<World*>(this)->Store<T>();
}
template <class T>
T& Add(Entity e, const T& value) { return Store<T>().Add(e, value); }
template <class T>
T* Get(Entity e) { return Store<T>().Find(e); }
template <class T>
const T* Get(Entity e) const { return Store<T>().Find(e); }
template <class T>
std::vector<T>& All() { return Store<T>().data; }
void Clear()
{
for (auto& p : pools)
if (p) p->Clear();
alive.clear();
generation.clear();
freeSlots.clear();
}
private:
std::vector<uint8_t> alive;
std::vector<uint8_t> generation;
std::vector<uint32_t> freeSlots;
std::vector<std::unique_ptr<IPool>> pools;
};
} // namespace ecs

557
src/game.cpp Normal file
View File

@ -0,0 +1,557 @@
#include "game.h"
// Перевод экранных осей ввода в мировые — единственное место,
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
#include "render/iso.h"
namespace
{
// Снять с мира все пули разом. Пуля — сущность, поэтому просто очистить вектор
// нельзя: остались бы живые идентификаторы без компонентов.
void ClearBullets(ecs::World& world)
{
std::vector<Bullet>& bullets = world.All<Bullet>();
while (!bullets.empty()) world.Destroy(bullets.back().self);
}
} // namespace
Game::Game()
: targets(world.Store<Target>().data),
bullets(world.Store<Bullet>().data)
{
}
void Game::Init()
{
sprites.Generate();
Reset();
}
void Game::Reset()
{
map.Generate();
world.Clear(); // бойцы, оружие, мишени, пули — всё разом
squad.Init(world, {SQUAD_START_X, SQUAD_START_Y});
SpawnTargets(world);
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;
}
ClearBullets(world);
lanes.Clear();
metrics.Reset();
events.Clear();
simTime = 0.0f;
}
SolverContext Game::MakeContext() const
{
SolverContext ctx;
ctx.map = &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::Step(float dt)
{
if (in.setFormation >= 0 && in.setFormation < int(Formation::COUNT))
squad.formation = Formation(in.setFormation);
// --- 0. статы снаряжения: с сущностей-оружия на бойцов ---------------------
// Одна система в начале шага. Всё остальное дальше читает уже готовые числа
// и не лазает в ECS: ai/ про мир сущностей не знает вообще.
for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i]);
UpdateTargets(targets, dt);
// --- 1. якорь и состояние отряда -----------------------------------------
// Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде.
const Vec2 moveWorld = iso::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.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.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];
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.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);
// --- 10. метрики -----------------------------------------------------------
metrics.Sample(squad, dt);
simTime += dt;
}
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[NUM_TARGETS];
int coneCount = 0;
for (int i = 0; i < int(targets.size()); ++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.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;
g.ammo = g.def.magazine;
events.Push(CombatEventKind::RELOAD_DONE, i, a.pos, FromAngle(a.facing));
}
continue;
}
if (g.Empty())
{
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;
// Отклонение внутри уже проверенного конуса.
const float spread = a.SpreadRad();
const float axis = AngleOf(Normalized(tp - muzzle, FromAngle(a.facing)));
const Vec2 dir = FromAngle(axis + g.ShotOffset(i, spread));
SpawnBullet(world, g.def, muzzle, dir, Distance(muzzle, tp), 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.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. мишени, по одной за взмах
for (int t = 0; t < int(targets.size()); ++t)
{
Target& tg = targets[t];
if (!tg.alive) continue;
if (m.hitMask & (1u << 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 |= (1u << t);
a.fired = true;
const Vec2 dir = Normalized(tg.pos - a.pos, FromAngle(m.dir));
const Vec2 contact = tg.pos - dir * tg.radius;
tg.hp -= m.curDamage;
KnockTarget(tg, dir, m.curKnock);
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 = g_tune.targetRespawn;
++metrics.targetsKilled;
events.Push(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;
}
}
}
}

83
src/game.h Normal file
View File

@ -0,0 +1,83 @@
#pragma once
// -----------------------------------------------------------------------------
// Контейнер состояния прототипа: карта, мир ECS, отряд, мишени, пули, метрики.
// main.cpp занимается только окном и fixed timestep, вся склейка — здесь.
// Ввод приходит снаружи структурой InputState (нужно и для headless-прогонов).
//
// Мишени, пули, бойцы и оружие — сущности одного мира ecs::World. targets и
// bullets ниже — это ССЫЛКИ на плотные массивы соответствующих пулов, а не
// отдельные контейнеры: правила ходят по ним как по обычным векторам и про ECS
// ничего не знают (docs/12-ecs.md).
// -----------------------------------------------------------------------------
#include <vector>
#include "ai/firing_solver.h"
#include "ai/lane_registry.h"
#include "ai/melee.h"
#include "core/input.h"
#include "core/math.h"
#include "debug/metrics.h"
#include "ecs/ecs.h"
#include "render/sprites.h"
#include "sim/events.h"
#include "sim/bullet.h"
#include "sim/squad.h"
#include "sim/target.h"
#include "world/tilemap.h"
// Ручное прицеливание (спек 6.8).
struct AimState
{
bool manual = false;
float dir = 0.0f; // радианы, куда смотрит вектор прицела
int assignedTargetId = -1;
};
struct Game
{
Tilemap map;
SpriteSet sprites;
ecs::World world;
Squad squad;
LaneRegistry lanes;
std::vector<Target>& targets; // пул ECS
std::vector<Bullet>& bullets; // пул ECS
Metrics metrics;
AimState aim;
// Единственный канал в сторону эффектов. Правила его не читают.
CombatEventLog events;
// Последний прогон решателя по каждому агенту — только для F3-оверлея.
SolveResult lastSolve[NUM_AGENTS];
bool lastSolveValid[NUM_AGENTS] = {};
// Dev-тумблеры для A/B проверки ядра (по умолчанию включено).
bool enableSolver = true;
bool enableLaneAvoid = true;
InputState in{};
float simTime = 0.0f;
Game();
void Init(); // одноразовая инициализация (спрайты + Reset)
void Reset(); // R: перегенерировать уровень и расставить отряд
// Поставить отряд в заданную точку. clumped = все пятеро в одной куче —
// это исходная позиция сценария из критерия приёмки 11.1.
void PlaceSquad(Vec2 at, bool clumped);
void Step(float dt); // ровно один шаг фиксированной симуляции
SolverContext MakeContext() const;
private:
void UpdateAim(float dt);
// Перезарядка, увод ствола, спуск. Разброс выстрела считается здесь же и
// ровно тот, который проверил LaneClear.
void FireWeapons(const SolverContext& ctx, float dt);
// Машина состояний клинка: замах -> удар -> восстановление, урон по дуге.
void UpdateMelee(const SolverContext& ctx, float dt);
};

181
src/main.cpp Normal file
View File

@ -0,0 +1,181 @@
// -----------------------------------------------------------------------------
// Точка входа: окно, ввод, главный цикл, fixed timestep 60 Гц.
// Ничего игрового здесь нет — только сбор ввода и вызовы Game::Step/Render.
//
// Dev-режимы (подробнее в docs/01-build-and-run.md):
// --shot [кадров] [файл] снять скриншот и выйти
// --clean стартовать без оверлея (чистый кадр)
// --headless <сек> прогон без окна + метрики
// --accept сценарии критериев приёмки (спек 11)
// --probe подробности по агентам на стоянках
// --no-solver / --no-laneavoid отключить части ядра для A/B сравнения
// --melee N сколько слотов держат клинок (0 = все стрелки)
// --pistol N сколько ПОСЛЕДНИХ слотов носят пистолет
// --set имя=значение подкрутить параметр из TUNABLES
// -----------------------------------------------------------------------------
#include <raylib.h>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include "debug/harness.h"
#include "debug/overlay.h"
#include "game.h"
#include "render/framebuffer.h"
#include "render/view.h"
#include "tuning.h"
namespace
{
InputState SampleInput()
{
InputState in;
if (IsKeyDown(KEY_A)) in.moveScreen.x -= 1.0f;
if (IsKeyDown(KEY_D)) in.moveScreen.x += 1.0f;
if (IsKeyDown(KEY_W)) in.moveScreen.y -= 1.0f;
if (IsKeyDown(KEY_S)) in.moveScreen.y += 1.0f;
in.sprint = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
in.toggleAimMode = IsKeyPressed(KEY_SPACE);
in.cycleTarget = IsKeyPressed(KEY_TAB);
if (IsKeyDown(KEY_LEFT)) in.aimTurn -= 1.0f;
if (IsKeyDown(KEY_RIGHT)) in.aimTurn += 1.0f;
if (IsKeyPressed(KEY_ONE)) in.setFormation = 0;
if (IsKeyPressed(KEY_TWO)) in.setFormation = 1;
if (IsKeyPressed(KEY_THREE)) in.setFormation = 2;
return in;
}
// --set имя=значение — подкрутка любого параметра из TUNABLES без пересборки.
void ApplyTuningOverride(const char* arg)
{
const char* eq = std::strchr(arg, '=');
if (!eq) return;
const size_t nameLen = size_t(eq - arg);
for (int k = 0; k < TUNABLE_COUNT; ++k)
{
if (std::strlen(TUNABLES[k].name) != nameLen) continue;
if (std::strncmp(arg, TUNABLES[k].name, nameLen) != 0) continue;
g_tune.*(TUNABLES[k].member) = float(std::atof(eq + 1));
std::printf("set %s = %s\n", TUNABLES[k].name, eq + 1);
return;
}
std::printf("неизвестный параметр: %s\n", arg);
}
} // namespace
int main(int argc, char** argv)
{
int shotFrame = -1;
const char* shotFile = "shot.png";
bool accept = false;
bool clean = false; // --clean: стартовать без оверлея (для скриншотов)
HarnessOptions harness;
harness.seconds = 0.0f;
for (int i = 1; i < argc; ++i)
{
if (std::strcmp(argv[i], "--shot") == 0)
{
shotFrame = (i + 1 < argc) ? TextToInteger(argv[i + 1]) : 90;
if (i + 2 < argc) shotFile = argv[i + 2];
}
else if (std::strcmp(argv[i], "--headless") == 0 && i + 1 < argc)
harness.seconds = float(std::atof(argv[++i]));
else if (std::strcmp(argv[i], "--set") == 0 && i + 1 < argc)
ApplyTuningOverride(argv[++i]);
else if (std::strcmp(argv[i], "--melee") == 0 && i + 1 < argc)
harness.meleeCount = TextToInteger(argv[++i]);
else if (std::strcmp(argv[i], "--pistol") == 0 && i + 1 < argc)
harness.pistolCount = TextToInteger(argv[++i]);
else if (std::strcmp(argv[i], "--clean") == 0) clean = true;
else if (std::strcmp(argv[i], "--accept") == 0) accept = true;
else if (std::strcmp(argv[i], "--probe") == 0) harness.probe = true;
else if (std::strcmp(argv[i], "--no-solver") == 0) harness.solver = false;
else if (std::strcmp(argv[i], "--no-laneavoid") == 0) harness.laneAvoid = false;
}
if (accept) return RunAccept();
if (harness.seconds > 0.0f) return RunHeadless(harness);
SetConfigFlags(FLAG_VSYNC_HINT);
InitWindow(WINDOW_W, WINDOW_H, "Squad prototype");
SetExitKey(KEY_ESCAPE);
Framebuffer fb;
fb.Init(INTERNAL_W, INTERNAL_H);
ScreenBlitter screen;
screen.Init(INTERNAL_W, INTERNAL_H);
Game game;
game.enableSolver = harness.solver;
game.enableLaneAvoid = harness.laneAvoid;
game.squad.meleeCount = harness.meleeCount; // до Init: состав раздаётся там
game.squad.pistolCount = harness.pistolCount;
game.Init();
// View большой (поля света на всю карту + виньетка на кадр) — на куче.
static View view;
view.Init();
view.Reset(game);
Overlay overlay;
if (clean) { overlay.show = false; overlay.showLanes = false; }
double accumulator = 0.0;
while (!WindowShouldClose())
{
overlay.HandleKeys();
if (IsKeyPressed(KEY_R)) { game.Reset(); view.Reset(game); }
if (IsKeyPressed(KEY_F5)) view.horrorEnabled = !view.horrorEnabled;
if (IsKeyPressed(KEY_F6)) view.showSight = !view.showSight;
game.in = SampleInput();
const float frameDt = GetFrameTime();
accumulator += double(frameDt);
// --- fixed timestep 60 Гц, рендер с интерполяцией ---------------------
int steps = 0;
const auto t0 = std::chrono::steady_clock::now();
while (accumulator >= FIXED_DT && steps < MAX_STEPS_PER_FRAME)
{
game.Step(FIXED_DT);
accumulator -= FIXED_DT;
++steps;
}
game.metrics.simMs = float(
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count());
if (accumulator > FIXED_DT * MAX_STEPS_PER_FRAME) accumulator = 0.0;
const float alpha = float(accumulator / FIXED_DT);
const auto tr0 = std::chrono::steady_clock::now();
RenderGame(game, view, fb, alpha, frameDt);
game.metrics.renderMs = float(
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - tr0).count());
BeginDrawing();
ClearBackground(BLACK);
screen.Present(fb, WINDOW_W, WINDOW_H);
overlay.Draw(game, view);
EndDrawing();
if (shotFrame >= 0 && --shotFrame <= 0)
{
TakeScreenshot(shotFile);
break;
}
}
screen.Shutdown();
CloseWindow();
return 0;
}

83
src/render/atmos.cpp Normal file
View File

@ -0,0 +1,83 @@
#include "render/atmos.h"
#include "render/iso.h"
#include "render/noise.h"
namespace
{
// Мировой квадрат вокруг камеры, гарантированно покрывающий кадр 480x270.
// В изометрии |wx-wy| <= 15 по ширине и |wx+wy| <= 17 по высоте, так что 19
// тайлов в каждую сторону — с запасом на подъём пылинки по экрану.
constexpr int ASH_REACH = 19;
// Больше трёх пылинок на тайл — это уже не воздух, а снегопад.
constexpr int ASH_PER_CELL = 3;
constexpr uint32_t COL_ASH = FXC(198, 186, 166); // тёплый серый: пепел, не снег
} // namespace
void Atmosphere::DrawAsh(Framebuffer& fb, Vec2 camPos, int ox, int oy, const Lighting& light) const
{
const float density = g_tune.ashDensity;
const float bright = g_tune.ashBrightness;
if (density <= 0.0f || bright <= 0.0f) return;
const int cx = int(std::floor(camPos.x));
const int cy = int(std::floor(camPos.y));
const float drift = g_tune.ashSpeed;
for (int gy = cy - ASH_REACH; gy <= cy + ASH_REACH; ++gy)
{
for (int gx = cx - ASH_REACH; gx <= cx + ASH_REACH; ++gx)
{
for (int k = 0; k < ASH_PER_CELL; ++k)
{
// Дробная плотность: последней пылинке в ячейке «положено»
// столько, сколько осталось от density.
const float quota = density - float(k);
if (quota <= 0.0f) break;
const uint32_t h = Hash3(uint32_t(gx + 4096), uint32_t(gy + 4096),
uint32_t(k) * 977u + 13u);
if (quota < 1.0f && Hash01(h) > quota) continue;
const Vec2 base{float(gx) + Hash01(Hash2(h, 1u)),
float(gy) + Hash01(Hash2(h, 2u))};
// Дрейф — замкнутая петля, а не бесконечный снос: иначе пылинку
// пришлось бы «заворачивать», и она телепортировалась бы на глазах.
const float ph = Hash01(Hash2(h, 3u)) * TWO_PI_F;
const float rate = 0.20f + 0.45f * Hash01(Hash2(h, 4u));
const float amp = 0.30f + 0.30f * Hash01(Hash2(h, 5u));
const float t = time * drift * rate;
const Vec2 p{base.x + std::cos(t * TWO_PI_F + ph) * amp,
base.y + std::sin(t * TWO_PI_F * 0.73f + ph * 1.7f) * amp};
const float lit = light.LightAt(p);
if (lit < 0.05f) continue;
// Высота: часть взвеси стелется по полу, часть висит на уровне
// головы. Медленное покачивание вверх-вниз добавляет объёма.
const float liftFrac = 0.15f + 0.95f * Hash01(Hash2(h, 6u));
const float bob = std::sin(t * TWO_PI_F * 1.31f + ph) * 2.5f;
const int lift = int(g_tune.ashLiftPx * liftFrac + bob);
const Vec2 s = iso::WorldToScreen(p);
const int sx = int(std::lround(s.x)) + ox;
const int sy = int(std::lround(s.y)) + oy - lift;
if (unsigned(sx) >= unsigned(fb.w) || unsigned(sy) >= unsigned(fb.h)) continue;
// Мерцание: пылинка поворачивается к свету то одним боком, то другим.
const float twinkle = 0.45f + 0.55f * (0.5f + 0.5f * std::sin(t * 9.0f + ph * 3.0f));
const float amount = lit * bright * twinkle * (0.35f + 0.65f * Hash01(Hash2(h, 8u)));
const uint32_t col = ScaleColor8(COL_ASH, uint32_t(Clampf(amount, 0.0f, 1.0f) * 256.0f));
// Крупные хлопья редки и получают ореол — глазу есть за что зацепиться.
if ((h & 15u) == 0u) fb.AddGlowDisc(sx, sy, 1, col);
else fb.AddPx(sx, sy, col);
}
}
}
}

32
src/render/atmos.h Normal file
View File

@ -0,0 +1,32 @@
#pragma once
// -----------------------------------------------------------------------------
// Воздух: висящая пыль (пепел) и низовая дымка.
//
// Разделение сознательное, потому что это две разные вещи:
// * ПЕПЕЛ — частицы в МИРЕ. Он парит между камерой и полом, двигается вместе
// со сценой при движении камеры и подчиняется свету: в темноте пылинок не
// видно, в луче они вспыхивают. Рисуется здесь;
// * ДЫМКА — свойство КАДРА, а не мира: мягкое подсвечивание объёма клубами.
// Она живёт в слитом пост-проходе (render/post.cpp), потому что там уже
// есть проход по пикселям и второй заводить незачем.
//
// Ни у того, ни у другого нет состояния, которое надо синхронизировать:
// положение пылинки — чистая функция от её ячейки мира и времени. Поэтому
// «частиц» нет как объектов, аллокаций ноль, и число пылинок можно менять
// ручкой в оверлее прямо на ходу.
// -----------------------------------------------------------------------------
#include "core/math.h"
#include "render/framebuffer.h"
#include "render/lighting.h"
struct Atmosphere
{
float time = 0.0f;
void Reset() { time = 0.0f; }
void Update(float frameDt) { time += frameDt; }
// Взвесь поверх геометрии и до пост-прохода: пыль светится аддитивно,
// но общую цветокоррекцию кадра проходит вместе со всем остальным.
void DrawAsh(Framebuffer& fb, Vec2 camPos, int ox, int oy, const Lighting& light) const;
};

233
src/render/blood.cpp Normal file
View File

@ -0,0 +1,233 @@
#include "render/blood.h"
#include "render/iso.h"
#include "render/noise.h"
namespace
{
// Максимум капель в одном пятне. Больше — это уже не брызги, а забор.
constexpr int MAX_DROPS = 16;
// Одна капля пятна: круг с рваным краем, при желании вытянутый вдоль удара.
struct Blob
{
Vec2 c{};
float r = 0.0f;
float strength = 1.0f;
float elong = 1.0f; // вытяжение вдоль dir; 1 = ровный круг
uint32_t seed = 0;
};
// Множитель цвета по каналам с насыщением. Кровь именно УМНОЖАЕТ пол, а не
// закрашивает его: фактура плитки должна просвечивать, иначе получается наклейка.
inline uint32_t MulColor(uint32_t c, float kr, float kg, float kb)
{
const float r = Clampf(float(c & 0xFFu) * kr, 0.0f, 255.0f);
const float g = Clampf(float((c >> 8) & 0xFFu) * kg, 0.0f, 255.0f);
const float b = Clampf(float((c >> 16) & 0xFFu) * kb, 0.0f, 255.0f);
return RGBA(uint8_t(r), uint8_t(g), uint8_t(b), AlphaOf(c));
}
inline uint32_t MixColor(uint32_t a, uint32_t b, float t)
{
const float k = Clampf(t, 0.0f, 1.0f);
return RGBA(uint8_t(Lerpf(float(a & 0xFFu), float(b & 0xFFu), k)),
uint8_t(Lerpf(float((a >> 8) & 0xFFu), float((b >> 8) & 0xFFu), k)),
uint8_t(Lerpf(float((a >> 16) & 0xFFu), float((b >> 16) & 0xFFu), k)),
AlphaOf(a));
}
// Рваный край: радиус зависит от угла, между лепестками — гладкая интерполяция.
// Идеальный круг читается как штамп, а не как лужа.
float EdgeWobble(uint32_t seed, float ang)
{
constexpr int LOBES = 12;
const float t = (ang + PI_F) / TWO_PI_F * float(LOBES);
const int i0 = int(t) % LOBES;
const int i1 = (i0 + 1) % LOBES;
float f = t - std::floor(t);
f = f * f * (3.0f - 2.0f * f);
const float w0 = Hash01(Hash3(seed, uint32_t(i0), 0x5Bu));
const float w1 = Hash01(Hash3(seed, uint32_t(i1), 0x5Bu));
return 0.72f + 0.44f * Lerpf(w0, w1, f);
}
} // namespace
void BloodField::Reset()
{
slotOf.assign(size_t(MAP_W) * size_t(MAP_H), -1);
stained.clear();
eventCursor = 0;
splats = 0;
}
void BloodField::Ingest(const CombatEventLog& log, const SpriteSet& sprites, const Tilemap& map)
{
uint32_t from = eventCursor;
if (log.head > from + COMBAT_EVENT_CAP) from = log.head - COMBAT_EVENT_CAP;
for (uint32_t i = from; i < log.head; ++i)
{
const CombatEvent& e = log.buf[i % COMBAT_EVENT_CAP];
float radius = 0.0f;
switch (e.kind)
{
case CombatEventKind::HIT_TARGET: radius = g_tune.bloodRadiusHit; break;
case CombatEventKind::HIT_ALLY: radius = g_tune.bloodRadiusHit; break;
case CombatEventKind::MELEE_HIT: radius = g_tune.bloodRadiusMelee; break;
case CombatEventKind::HIT_KILL: radius = g_tune.bloodRadiusKill; break;
default: continue;
}
// Зерно от номера события: два попадания в одну точку дадут разные пятна.
Splat(sprites, map, e.pos, e.dir, radius, Hash2(i, uint32_t(e.owner + 1)));
}
eventCursor = log.head;
}
void BloodField::Splat(const SpriteSet& sprites, const Tilemap& map, Vec2 world, Vec2 dir,
float radius, uint32_t seed)
{
if (g_tune.bloodAmount <= 0.0f) return;
radius *= g_tune.bloodAmount;
if (radius < 0.02f) return;
if (slotOf.empty()) Reset();
const Vec2 d = Normalized(dir, Vec2{1.0f, 0.0f});
const Vec2 side = Perp(d);
// --- набор капель ---------------------------------------------------------
Blob blobs[1 + MAX_DROPS];
int n = 0;
Blob main_;
main_.c = world;
main_.r = radius;
main_.strength = 1.0f;
main_.elong = 1.35f; // лужа вытянута по удару, а не идеально кругла
main_.seed = seed;
blobs[n++] = main_;
const int drops = std::min(MAX_DROPS, std::max(0, int(g_tune.bloodDrops)));
for (int k = 0; k < drops; ++k)
{
const uint32_t h = Hash3(seed, uint32_t(k), 0x11u);
const float along = radius * (1.05f + 2.4f * (float(k) + 1.0f) / float(drops)) *
(0.55f + 0.9f * Hash01(h));
const float lateral = radius * 1.15f * HashSigned(Hash2(h, 7u)) *
(0.3f + 0.7f * float(k) / float(drops));
const float rr = radius * (0.34f - 0.20f * float(k) / float(drops)) *
(0.45f + 0.9f * Hash01(Hash2(h, 13u)));
if (rr < 0.02f) continue;
Blob b;
b.c = world + d * along + side * lateral;
b.r = rr;
b.strength = 0.86f;
b.elong = 1.0f;
b.seed = h;
blobs[n++] = b;
}
// --- границы в мире -> диапазон тайлов ------------------------------------
float minX = world.x, maxX = world.x, minY = world.y, maxY = world.y;
for (int i = 0; i < n; ++i)
{
const float r = blobs[i].r * 1.5f;
minX = std::min(minX, blobs[i].c.x - r);
maxX = std::max(maxX, blobs[i].c.x + r);
minY = std::min(minY, blobs[i].c.y - r);
maxY = std::max(maxY, blobs[i].c.y + r);
}
const int tx0 = int(std::floor(minX));
const int tx1 = int(std::floor(maxX));
const int ty0 = int(std::floor(minY));
const int ty1 = int(std::floor(maxY));
const uint32_t coreCol = RGBA(52, 8, 10);
const uint32_t rimCol = RGBA(104, 22, 16);
const float opacity = Clampf(g_tune.bloodOpacity, 0.0f, 1.0f);
bool painted = false;
for (int ty = ty0; ty <= ty1; ++ty)
{
for (int tx = tx0; tx <= tx1; ++tx)
{
if (!Tilemap::InBounds(tx, ty)) continue;
if (map.IsWall(tx, ty)) continue; // под стеной пола не видно
const size_t key = size_t(ty) * MAP_W + tx;
int32_t slot = slotOf[key];
if (slot < 0)
{
// Бюджет выбран: на чистый тайл больше не тратимся, но уже
// запачканные продолжают собирать кровь.
if (int(stained.size()) >= BLOOD_TILE_BUDGET) continue;
slot = int32_t(stained.size());
stained.push_back(sprites.FloorTile(tx, ty));
slotOf[key] = slot;
}
Sprite& s = stained[size_t(slot)];
const Vec2 center{float(tx) + 0.5f, float(ty) + 0.5f};
for (int py = 0; py < s.h; ++py)
{
for (int px = 0; px < s.w; ++px)
{
const uint32_t base = s.Get(px, py);
if (AlphaOf(base) == 0) continue; // вне ромба
// Пиксель ромба -> точка мира. Проекция линейна, поэтому
// обратный переход точен и швов между тайлами не будет.
const Vec2 p = center + iso::ScreenToWorld(
Vec2{float(px - s.ax), float(py - s.ay)});
float cover = 0.0f;
for (int i = 0; i < n && cover < 1.0f; ++i)
{
const Blob& b = blobs[i];
Vec2 dv = p - b.c;
if (b.elong != 1.0f)
dv = Vec2{Dot(dv, d) / b.elong, Dot(dv, side)};
const float dist = Length(dv);
const float r = b.r * EdgeWobble(b.seed, AngleOf(dv));
if (dist >= r) continue;
const float inner = r * 0.68f;
const float a = (dist <= inner)
? 1.0f
: 1.0f - (dist - inner) / std::max(1e-4f, r - inner);
cover = std::max(cover, b.strength * a);
}
if (cover <= 0.02f) continue;
// Кромка светлее и рыжее середины — так лужа читается объёмной.
const uint32_t tint = MixColor(rimCol, coreCol, Clampf(cover * 1.3f, 0.0f, 1.0f));
// Крапчатость: свернувшаяся кровь неоднородна.
const float mott = 0.84f + 0.26f * Hash01(Hash3(uint32_t(tx * 64 + px),
uint32_t(ty * 64 + py), seed));
// Плотность держится почти по всему пятну и падает только
// у самого края: лужа с ровным градиентом выглядит подтёком.
const float a = Clampf(cover * 1.5f, 0.0f, 1.0f) * opacity * mott;
// Делители подобраны так, чтобы серый пол ушёл в тёмно-красное,
// а кромка — в заметно более светлое и рыжее.
const uint32_t stainedPx =
MulColor(base, Lerpf(1.0f, float(tint & 0xFFu) / 55.0f, a),
Lerpf(1.0f, float((tint >> 8) & 0xFFu) / 110.0f, a),
Lerpf(1.0f, float((tint >> 16) & 0xFFu) / 150.0f, a));
s.Set(px, py, stainedPx);
painted = true;
}
}
}
}
if (painted) ++splats;
}

56
src/render/blood.h Normal file
View File

@ -0,0 +1,56 @@
#pragma once
// -----------------------------------------------------------------------------
// Кровь: вечные декали, ЗАПЕЧЁННЫЕ в поверхность.
//
// Почему не список пятен, которые дорисовываются каждый кадр: их число растёт
// без предела, и через минуту боя кадр начинает стоить столько же, сколько вся
// остальная сцена. Обычное лекарство — выбрасывать старые декали, но задача
// прямо противоположная: пятна должны остаться навсегда.
//
// Решение — запекание. Пол рисуется поштучно спрайтами-ромбами. При попадании
// мы один раз делаем КОПИЮ ромба того тайла, куда пришлось попадание, и рисуем
// кровь прямо в её пиксели. Дальше этот тайл рисуется ровно так же, как раньше,
// просто другим спрайтом. Стоимость кадра не меняется НИ НА ЧТО, сколько бы
// крови ни накопилось: цена платится один раз, в момент попадания.
//
// Свет и туман войны продолжают работать: запечённая кровь проходит через ту же
// световую рампу, что и пол, потому что она и есть пол.
//
// Потолок памяти — BLOOD_TILE_BUDGET тайлов (по 2 КБ). Когда он выбран, новые
// пятна ложатся только на уже запачканные тайлы: это видно как «кровь копится
// там, где дрались», а не как исчезающие декали.
// -----------------------------------------------------------------------------
#include <cstdint>
#include <vector>
#include "core/math.h"
#include "render/framebuffer.h"
#include "render/sprites.h"
#include "sim/events.h"
#include "world/tilemap.h"
struct BloodField
{
std::vector<int32_t> slotOf; // тайл -> индекс в stained, -1 = чистый
std::vector<Sprite> stained; // запечённые копии ромбов пола
uint32_t eventCursor = 0;
int splats = 0; // сколько пятен легло за прогон (для оверлея)
void Reset();
// Чем рисовать пол этого тайла: запечённой копией или ничем (значит базовым).
const Sprite* At(int tx, int ty) const
{
if (!Tilemap::InBounds(tx, ty)) return nullptr;
if (slotOf.empty()) return nullptr;
const int32_t s = slotOf[size_t(ty) * MAP_W + tx];
return (s >= 0) ? &stained[size_t(s)] : nullptr;
}
// Забрать из журнала боя всё, что кровоточит.
void Ingest(const CombatEventLog& log, const SpriteSet& sprites, const Tilemap& map);
// Одно пятно: основная лужа + брызги по направлению удара.
void Splat(const SpriteSet& sprites, const Tilemap& map, Vec2 world, Vec2 dir, float radius,
uint32_t seed);
};

332
src/render/framebuffer.cpp Normal file
View File

@ -0,0 +1,332 @@
#include "render/framebuffer.h"
#include <raylib.h>
#include <cmath>
#include <cstdlib>
// -----------------------------------------------------------------------------
// Световая рампа
// -----------------------------------------------------------------------------
uint8_t g_shadeLut[RAMP_LEVELS][3][256];
const uint8_t BAYER4[4][4] = {
{ 0, 8, 2, 10},
{12, 4, 14, 6},
{ 3, 11, 1, 9},
{15, 7, 13, 5},
};
namespace
{
// Цвет полумрака и цвет пересвета. Между ними рампа плавно интерполирует.
constexpr float COLD[3] = {0.60f, 0.74f, 1.00f}; // холодный синий в тенях
constexpr float WARM[3] = {1.00f, 0.92f, 0.76f}; // тёплый пересвет во вспышке
inline float Smooth01(float t) { return t * t * (3.0f - 2.0f * t); }
struct ShadeRampInit
{
ShadeRampInit() { BuildShadeRamp(1.35f, 1.10f); }
};
ShadeRampInit g_rampInit;
} // namespace
void BuildShadeRamp(float gamma, float maxGain)
{
for (int lvl = 0; lvl < RAMP_LEVELS; ++lvl)
{
// Ступень 0 — чистый чёрный: она обязана совпадать с цветом Clear,
// иначе невидимые тайлы нельзя пропускать при отрисовке.
if (lvl == 0)
{
for (int ch = 0; ch < 3; ++ch)
for (int v = 0; v < 256; ++v) g_shadeLut[lvl][ch][v] = 0;
continue;
}
const float t = float(lvl) / float(RAMP_LEVELS - 1);
const float gain = std::pow(t, gamma) * maxGain;
const float mix = Smooth01(t);
for (int ch = 0; ch < 3; ++ch)
{
const float tint = COLD[ch] + (WARM[ch] - COLD[ch]) * mix;
const float k = gain * tint;
for (int v = 0; v < 256; ++v)
{
const float out = float(v) * k;
g_shadeLut[lvl][ch][v] = uint8_t(out < 0.0f ? 0.0f : (out > 255.0f ? 255.0f : out));
}
}
}
}
// -----------------------------------------------------------------------------
void Framebuffer::Init(int width, int height)
{
w = width;
h = height;
px.assign(size_t(w) * size_t(h), 0u);
}
void Framebuffer::Clear(uint32_t color)
{
std::fill(px.begin(), px.end(), color);
}
namespace
{
// Общая обрезка спрайта по буферу. false — рисовать нечего.
inline bool ClipSprite(const Sprite& s, int x, int y, int fbW, int fbH, int& dstX0, int& dstY0,
int& sx0, int& sy0, int& sx1, int& sy1)
{
dstX0 = x - s.ax;
dstY0 = y - s.ay;
sx0 = std::max(0, -dstX0);
sy0 = std::max(0, -dstY0);
sx1 = std::min(s.w, fbW - dstX0);
sy1 = std::min(s.h, fbH - dstY0);
// Пустой диапазон -> выходим ДО вычисления адресов: иначе индекс уезжает
// за границы vector (Debug ловит это как "vector subscript out of range").
return sx0 < sx1 && sy0 < sy1;
}
} // namespace
void Framebuffer::Blit(const Sprite& s, int x, int y)
{
int dstX0, dstY0, sx0, sy0, sx1, sy1;
if (!ClipSprite(s, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
for (int sy = sy0; sy < sy1; ++sy)
{
const uint32_t* src = s.px.data() + size_t(sy) * s.w + sx0;
uint32_t* dst = px.data() + size_t(dstY0 + sy) * w + (dstX0 + sx0);
for (int sx = sx0; sx < sx1; ++sx, ++src, ++dst)
{
const uint32_t c = *src;
if (AlphaOf(c) != 0) *dst = c; // альфа-тест, без блендинга
}
}
}
void Framebuffer::BlitTinted(const Sprite& s, int x, int y, uint32_t tint)
{
int dstX0, dstY0, sx0, sy0, sx1, sy1;
if (!ClipSprite(s, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
for (int sy = sy0; sy < sy1; ++sy)
for (int sx = sx0; sx < sx1; ++sx)
if (AlphaOf(s.px[size_t(sy) * s.w + sx]) != 0)
px[size_t(dstY0 + sy) * w + (dstX0 + sx)] = tint;
}
void Framebuffer::BlitShaded(const Sprite& s, int x, int y, const ShadeQuad& q)
{
int dstX0, dstY0, sx0, sy0, sx1, sy1;
if (!ClipSprite(s, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
constexpr float MAX_LVL = float(RAMP_LEVELS - 1);
for (int sy = sy0; sy < sy1; ++sy)
{
const float py = float(sy < q.clampRow ? sy : q.clampRow);
const float rowBase = q.a + q.c * py + q.f * py * py;
// Прямые разности по строке: во внутреннем цикле два сложения.
const float fx0 = float(sx0);
float lit = rowBase + q.b * fx0 + q.e * fx0 * fx0;
float dl = q.b + q.e * (2.0f * fx0 + 1.0f);
const float ddl = 2.0f * q.e;
const uint8_t* bayerRow = BAYER4[(dstY0 + sy) & 3];
const uint32_t* src = s.px.data() + size_t(sy) * s.w + sx0;
uint32_t* dst = px.data() + size_t(dstY0 + sy) * w + (dstX0 + sx0);
for (int sx = sx0; sx < sx1; ++sx, ++src, ++dst)
{
const uint32_t c = *src;
if (AlphaOf(c) != 0)
{
const float dither = (float(bayerRow[(dstX0 + sx) & 3]) + 0.5f) * (1.0f / 16.0f);
int lvl = int(lit * MAX_LVL + dither);
if (lvl < 0) lvl = 0;
else if (lvl >= RAMP_LEVELS) lvl = RAMP_LEVELS - 1;
if (lvl != 0) *dst = ShadePixel(c, lvl);
}
lit += dl;
dl += ddl;
}
}
}
void Framebuffer::BlitMul(const Sprite& mask, int x, int y, uint32_t k256)
{
int dstX0, dstY0, sx0, sy0, sx1, sy1;
if (!ClipSprite(mask, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
for (int sy = sy0; sy < sy1; ++sy)
for (int sx = sx0; sx < sx1; ++sx)
if (AlphaOf(mask.px[size_t(sy) * mask.w + sx]) != 0)
{
uint32_t& d = px[size_t(dstY0 + sy) * w + (dstX0 + sx)];
d = ScaleColor8(d, k256);
}
}
void Framebuffer::FillRect(int x, int y, int rw, int rh, uint32_t c)
{
const int x0 = std::max(0, x);
const int y0 = std::max(0, y);
const int x1 = std::min(w, x + rw);
const int y1 = std::min(h, y + rh);
for (int yy = y0; yy < y1; ++yy)
for (int xx = x0; xx < x1; ++xx)
px[size_t(yy) * w + xx] = c;
}
void Framebuffer::Line(int x0, int y0, int x1, int y1, uint32_t c)
{
// Брезенхем. Put сам отсекает по границам.
int dx = std::abs(x1 - x0);
int dy = -std::abs(y1 - y0);
const int sx = (x0 < x1) ? 1 : -1;
const int sy = (y0 < y1) ? 1 : -1;
int err = dx + dy;
for (;;)
{
Put(x0, y0, c);
if (x0 == x1 && y0 == y1) break;
const int e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
void Framebuffer::CircleOutline(int cx, int cy, int r, uint32_t c)
{
int x = r;
int y = 0;
int err = 1 - r;
while (x >= y)
{
Put(cx + x, cy + y, c); Put(cx + y, cy + x, c);
Put(cx - y, cy + x, c); Put(cx - x, cy + y, c);
Put(cx - x, cy - y, c); Put(cx - y, cy - x, c);
Put(cx + y, cy - x, c); Put(cx + x, cy - y, c);
++y;
if (err < 0) err += 2 * y + 1;
else { --x; err += 2 * (y - x) + 1; }
}
}
void Framebuffer::CircleFilled(int cx, int cy, int r, uint32_t c)
{
const int r2 = r * r;
for (int y = -r; y <= r; ++y)
for (int x = -r; x <= r; ++x)
if (x * x + y * y <= r2) Put(cx + x, cy + y, c);
}
// -----------------------------------------------------------------------------
// Аддитивные примитивы
// -----------------------------------------------------------------------------
void Framebuffer::AddBeam(float x0, float y0, float x1, float y1, uint32_t headCol,
uint32_t tailCol, int halo)
{
// Отсечение по расширенному прямоугольнику кадра: длинный закадровый штрих
// не должен крутить тысячи холостых итераций.
const float pad = float(halo) + 2.0f;
if ((x0 < -pad && x1 < -pad) || (x0 > float(w) + pad && x1 > float(w) + pad)) return;
if ((y0 < -pad && y1 < -pad) || (y0 > float(h) + pad && y1 > float(h) + pad)) return;
const float dx = x1 - x0;
const float dy = y1 - y0;
const float len = std::sqrt(dx * dx + dy * dy);
const int steps = int(len) + 1;
if (steps > 4 * (w + h)) return; // защита от вырожденных значений
const float inv = 1.0f / float(steps);
for (int i = 0; i <= steps; ++i)
{
const float t = float(i) * inv; // 0 = хвост, 1 = голова
const float px_ = x0 + dx * t;
const float py_ = y0 + dy * t;
// Цвет плывёт от хвоста к голове; хвост дополнительно гаснет.
const uint32_t core = AddSat(ScaleColor8(tailCol, uint32_t((1.0f - t) * 256.0f)),
ScaleColor8(headCol, uint32_t(t * 256.0f)));
const int ix = int(px_);
const int iy = int(py_);
AddPx(ix, iy, core);
// Боковой ореол: та же линия вполсилы на соседних пикселях.
if (halo > 0)
{
const uint32_t glow = ScaleColor8(core, 96);
AddPx(ix + 1, iy, glow);
AddPx(ix - 1, iy, glow);
AddPx(ix, iy + 1, glow);
AddPx(ix, iy - 1, glow);
}
}
}
void Framebuffer::AddGlowDisc(int cx, int cy, int radius, uint32_t color)
{
if (radius <= 0) return;
if (cx + radius < 0 || cx - radius >= w || cy + radius < 0 || cy - radius >= h) return;
const float inv = 1.0f / float(radius);
for (int y = -radius; y <= radius; ++y)
{
for (int x = -radius; x <= radius; ++x)
{
const float d = std::sqrt(float(x * x + y * y)) * inv;
if (d >= 1.0f) continue;
const float k = (1.0f - d) * (1.0f - d); // квадратичное затухание
AddPx(cx + x, cy + y, ScaleColor8(color, uint32_t(k * 256.0f)));
}
}
}
// -----------------------------------------------------------------------------
void ScreenBlitter::Init(int width, int height)
{
Image img = GenImageColor(width, height, BLANK);
ImageFormat(&img, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8);
Texture2D* t = new Texture2D(LoadTextureFromImage(img));
UnloadImage(img);
SetTextureFilter(*t, TEXTURE_FILTER_POINT);
tex = t;
texW = width;
texH = height;
}
void ScreenBlitter::Shutdown()
{
if (!tex) return;
Texture2D* t = static_cast<Texture2D*>(tex);
UnloadTexture(*t);
delete t;
tex = nullptr;
}
void ScreenBlitter::Present(const Framebuffer& fb, int windowW, int windowH) const
{
if (!tex) return;
if (fb.w != texW || fb.h != texH) return; // размер разошёлся — не грузим мусор
Texture2D* t = static_cast<Texture2D*>(tex);
UpdateTexture(*t, fb.px.data());
const Rectangle src = {0.0f, 0.0f, float(fb.w), float(fb.h)};
const Rectangle dst = {0.0f, 0.0f, float(windowW), float(windowH)};
DrawTexturePro(*t, src, dst, {0.0f, 0.0f}, 0.0f, WHITE);
}

192
src/render/framebuffer.h Normal file
View File

@ -0,0 +1,192 @@
#pragma once
// -----------------------------------------------------------------------------
// Софтверный фреймбуфер: RGBA8, ручная запись пикселей.
// Ни один игровой пиксель не рисуется примитивами raylib.
// raylib используется только в ScreenBlitter — финальный блит на экран.
//
// Инварианты (нарушение любого ломает картинку молча):
// * альфа фреймбуфера всегда 255;
// * ступень 0 световой рампы даёт чистый чёрный и совпадает с цветом Clear;
// * спрайты пишутся с альфа-тестом, эффекты — аддитивно.
// -----------------------------------------------------------------------------
#include <algorithm>
#include <cstdint>
#include <vector>
// В памяти байты идут R,G,B,A (PIXELFORMAT_UNCOMPRESSED_R8G8B8A8),
// значит little-endian uint32 = A<<24 | B<<16 | G<<8 | R.
constexpr uint32_t RGBA(uint8_t r, uint8_t g, uint8_t b, uint8_t a = 255)
{
return uint32_t(r) | (uint32_t(g) << 8) | (uint32_t(b) << 16) | (uint32_t(a) << 24);
}
constexpr uint8_t AlphaOf(uint32_t c) { return uint8_t(c >> 24); }
// Цвет для аддитивных эффектов: альфа 0, чтобы такой цвет нельзя было случайно
// использовать как непрозрачный пиксель спрайта.
constexpr uint32_t FXC(uint8_t r, uint8_t g, uint8_t b) { return RGBA(r, g, b, 0); }
// Сложение цветов с насыщением по каждому каналу.
inline uint32_t AddSat(uint32_t a, uint32_t b)
{
uint32_t r = (a & 0xFFu) + (b & 0xFFu);
uint32_t g = ((a >> 8) & 0xFFu) + ((b >> 8) & 0xFFu);
uint32_t bl = ((a >> 16) & 0xFFu) + ((b >> 16) & 0xFFu);
if (r > 255u) r = 255u;
if (g > 255u) g = 255u;
if (bl > 255u) bl = 255u;
return 0xFF000000u | r | (g << 8) | (bl << 16);
}
// Умножение цвета на коэффициент в формате 8.8 (256 == x1.0).
inline uint32_t ScaleColor8(uint32_t c, uint32_t k256)
{
uint32_t r = ((c & 0xFFu) * k256) >> 8;
uint32_t g = (((c >> 8) & 0xFFu) * k256) >> 8;
uint32_t bl = (((c >> 16) & 0xFFu) * k256) >> 8;
if (r > 255u) r = 255u;
if (g > 255u) g = 255u;
if (bl > 255u) bl = 255u;
return 0xFF000000u | r | (g << 8) | (bl << 16);
}
// -----------------------------------------------------------------------------
// Световая рампа (по мотивам COLORMAP из Doom).
//
// Свет — одна скалярная величина на точку мира. Таблица переводит её в цвет:
// в полумраке картинка уходит в холодный синий, на свету нейтральна, во вспышке
// выбивается в тёплый пересвет. На пиксель остаются три выборки из таблицы
// и ноль умножений.
// -----------------------------------------------------------------------------
constexpr int RAMP_LEVELS = 32;
extern uint8_t g_shadeLut[RAMP_LEVELS][3][256];
// Перепечь таблицу. Вызывать при изменении гаммы/усиления, иначе ручки молчат.
void BuildShadeRamp(float gamma, float maxGain);
// Упорядоченный дизеринг: 32 ступени рампы выглядят как ~200, полос не видно.
extern const uint8_t BAYER4[4][4];
inline uint32_t ShadePixel(uint32_t c, int lvl)
{
const uint8_t* lr = g_shadeLut[lvl][0];
const uint8_t* lg = g_shadeLut[lvl][1];
const uint8_t* lb = g_shadeLut[lvl][2];
return 0xFF000000u | uint32_t(lr[c & 0xFFu]) | (uint32_t(lg[(c >> 8) & 0xFFu]) << 8) |
(uint32_t(lb[(c >> 16) & 0xFFu]) << 16);
}
// Билинейный патч света по спрайту: L(px,py) = a + b*px + c*py + e*px^2 + f*py^2.
// Такая форма — это ТОЧНАЯ билинейка по четырём углам ромба тайла, записанная
// в координатах спрайта; поэтому на общем ребре соседних тайлов значения
// совпадают и швов нет.
struct ShadeQuad
{
float a = 1.0f;
float b = 0.0f;
float c = 0.0f;
float e = 0.0f;
float f = 0.0f;
// Строки ниже clampRow светятся как clampRow: вертикальные грани стены
// берут свет нижней кромки своей крыши, а не экстраполяцию квадратичного
// члена на 32 строки вниз.
int clampRow = 1 << 24;
static ShadeQuad Flat(float level)
{
ShadeQuad q;
q.a = level;
return q;
}
};
// Спрайт с альфа-тестом: px[i] с alpha==0 не пишется. Блендинга нет.
// (ax, ay) — точка привязки внутри спрайта: она совмещается с экранной
// проекцией мировой позиции сущности.
struct Sprite
{
int w = 0;
int h = 0;
int ax = 0;
int ay = 0;
std::vector<uint32_t> px;
void Init(int width, int height, uint32_t fill = 0)
{
w = width;
h = height;
px.assign(size_t(w) * size_t(h), fill);
}
void Set(int x, int y, uint32_t c)
{
if (unsigned(x) < unsigned(w) && unsigned(y) < unsigned(h)) px[size_t(y) * w + x] = c;
}
uint32_t Get(int x, int y) const
{
if (unsigned(x) < unsigned(w) && unsigned(y) < unsigned(h)) return px[size_t(y) * w + x];
return 0;
}
};
struct Framebuffer
{
int w = 0;
int h = 0;
std::vector<uint32_t> px;
void Init(int width, int height);
void Clear(uint32_t color);
inline void Put(int x, int y, uint32_t c)
{
if (unsigned(x) < unsigned(w) && unsigned(y) < unsigned(h)) px[size_t(y) * w + x] = c;
}
// Блит с альфа-тестом; (x, y) — куда попадёт точка привязки спрайта.
void Blit(const Sprite& s, int x, int y);
// Тот же блит, но каждый непрозрачный пиксель заменяется на tint (для силуэтов).
void BlitTinted(const Sprite& s, int x, int y, uint32_t tint);
// Блит со светом: цвет каждого пикселя проходит через рампу.
void BlitShaded(const Sprite& s, int x, int y, const ShadeQuad& q);
// Умножение фона под маской спрайта (контактные тени, лужи).
void BlitMul(const Sprite& mask, int x, int y, uint32_t k256);
void FillRect(int x, int y, int rw, int rh, uint32_t c);
void Line(int x0, int y0, int x1, int y1, uint32_t c);
void CircleOutline(int cx, int cy, int r, uint32_t c);
void CircleFilled(int cx, int cy, int r, uint32_t c);
// --- аддитивные примитивы для эффектов ---
inline void AddPx(int x, int y, uint32_t c)
{
if (unsigned(x) < unsigned(w) && unsigned(y) < unsigned(h))
{
uint32_t& d = px[size_t(y) * w + x];
d = AddSat(d, c);
}
}
// Светящийся штрих: яркое ядро + боковой ореол, цвет плывёт от head к tail.
void AddBeam(float x0, float y0, float x1, float y1, uint32_t headCol, uint32_t tailCol,
int halo);
// Мягкое пятно света (вспышка у дула, искра попадания).
void AddGlowDisc(int cx, int cy, int radius, uint32_t color);
};
// -----------------------------------------------------------------------------
// Финальный вывод: UpdateTexture + DrawTexturePro, фильтрация POINT.
// Единственное место рендера, знающее про raylib.
// -----------------------------------------------------------------------------
struct ScreenBlitter
{
void Init(int width, int height);
void Shutdown();
void Present(const Framebuffer& fb, int windowW, int windowH) const;
private:
void* tex = nullptr; // Texture2D на куче — чтобы не тащить raylib.h в заголовок
int texW = 0;
int texH = 0;
};

283
src/render/fx.cpp Normal file
View File

@ -0,0 +1,283 @@
#include "render/fx.h"
#include "render/sprites.h"
namespace
{
// Цвета эффектов: аддитивные, поэтому альфа 0 (см. FXC).
constexpr uint32_t COL_TRACER_TAIL = FXC(120, 70, 20);
constexpr uint32_t COL_MUZZLE = FXC(255, 225, 150);
constexpr uint32_t COL_SPARK_WALL = FXC(255, 190, 90);
constexpr uint32_t COL_SPARK_FLESH = FXC(220, 40, 40);
constexpr uint32_t COL_SPARK_KILL = FXC(255, 120, 60);
// Пурпурная искра — не украшение, а индикатор: попадание по своему.
// В норме её не должно появиться НИ РАЗУ (критерий приёмки 11.2).
constexpr uint32_t COL_SPARK_ALLY = FXC(255, 60, 255);
// Ближний бой: холодная сталь на замахе, раскалённый след на ударе.
constexpr uint32_t COL_BLADE_COLD = FXC(70, 110, 165);
constexpr uint32_t COL_BLADE_EDGE = FXC(255, 250, 235);
constexpr uint32_t COL_BLADE_TAIL = FXC(120, 165, 235);
constexpr uint32_t COL_SPARK_MELEE = FXC(255, 165, 95);
// Оборванный взмах — тусклая серая вспышка: свой влез под лезвие.
constexpr uint32_t COL_SPARK_ABORT = FXC(150, 150, 170);
inline Vec2 ToScreen(Vec2 world, int ox, int oy, int liftPx)
{
const Vec2 s = iso::WorldToScreen(world);
return Vec2{s.x + float(ox), s.y + float(oy) - float(liftPx)};
}
// Смешать два аддитивных цвета.
inline uint32_t MixFx(uint32_t a, uint32_t b, float t)
{
const uint32_t k = uint32_t(Clampf(t, 0.0f, 1.0f) * 256.0f);
return AddSat(ScaleColor8(a, 256u - k), ScaleColor8(b, k));
}
// Дуга в мировых координатах, спроецированная по точкам: в изометрии круг —
// это эллипс, и рисовать его надо сэмплами, а не экранной окружностью.
// Цвет плывёт от хвоста (colFrom) к голове (colTo) — так читается направление.
void AddWorldArc(Framebuffer& fb, Vec2 center, float radius, float angFrom, float angTo,
int ox, int oy, int lift, uint32_t colFrom, uint32_t colTo, int halo)
{
constexpr int SEG = 12;
Vec2 prev = ToScreen(center + FromAngle(angFrom) * radius, ox, oy, lift);
for (int k = 1; k <= SEG; ++k)
{
const float t = float(k) / float(SEG);
const Vec2 cur = ToScreen(center + FromAngle(angFrom + (angTo - angFrom) * t) * radius,
ox, oy, lift);
fb.AddBeam(prev.x, prev.y, cur.x, cur.y, MixFx(colFrom, colTo, t),
MixFx(colFrom, colTo, t - 1.0f / float(SEG)), halo);
prev = cur;
}
}
} // namespace
void FxSystem::Reset()
{
flashCount = 0;
sparkCount = 0;
eventCursor = 0;
shotShake = 0.0f;
}
void FxSystem::Ingest(const CombatEventLog& log, const Agent* agents, int agentCount)
{
uint32_t from = eventCursor;
if (log.head > from + COMBAT_EVENT_CAP) from = log.head - COMBAT_EVENT_CAP;
for (uint32_t i = from; i < log.head; ++i)
{
const CombatEvent& e = log.buf[i % COMBAT_EVENT_CAP];
if (e.kind == CombatEventKind::SHOT)
{
// Вид выстрела — свойство ствола, а не события: журнал носит только
// «кто, откуда, куда», а как это выглядит, знает оружие стрелка.
const bool known = (e.owner >= 0 && e.owner < agentCount);
const FirearmDef def = known ? agents[e.owner].gun.def : FirearmDef{};
shotShake += def.shakePerShot;
if (flashCount < FX_FLASH_MAX)
{
FxFlash f;
f.pos = e.pos;
f.dir = e.dir;
f.life = def.muzzleTime;
f.maxLife = def.muzzleTime;
f.radiusPx = def.muzzleRadiusPx;
f.flamePx = def.muzzleFlamePx;
f.color = FXC(def.muzzleR, def.muzzleG, def.muzzleB);
f.owner = e.owner;
flashes[flashCount++] = f;
}
continue;
}
uint32_t col = 0;
float power = 1.0f;
switch (e.kind)
{
case CombatEventKind::HIT_WALL: col = COL_SPARK_WALL; power = 1.0f; break;
case CombatEventKind::HIT_TARGET: col = COL_SPARK_FLESH; power = 0.8f; break;
case CombatEventKind::HIT_KILL: col = COL_SPARK_KILL; power = 1.8f; break;
case CombatEventKind::HIT_ALLY: col = COL_SPARK_ALLY; power = 2.5f; break;
// Удар клинком — самое громкое событие в кадре: он всегда в упор.
case CombatEventKind::MELEE_HIT: col = COL_SPARK_MELEE; power = 2.2f; break;
case CombatEventKind::MELEE_ABORT: col = COL_SPARK_ABORT; power = 0.7f; break;
default: continue;
}
if (sparkCount < FX_SPARK_MAX)
sparks[sparkCount++] = FxSpark{e.pos, e.dir, g_tune.sparkTime, power, col};
}
eventCursor = log.head;
}
void FxSystem::Update(float frameDt)
{
for (int i = 0; i < flashCount;)
{
flashes[i].life -= frameDt;
if (flashes[i].life <= 0.0f) flashes[i] = flashes[--flashCount];
else ++i;
}
for (int i = 0; i < sparkCount;)
{
sparks[i].life -= frameDt;
if (sparks[i].life <= 0.0f) sparks[i] = sparks[--sparkCount];
else ++i;
}
}
void FxSystem::Draw(Framebuffer& fb, const std::vector<Bullet>& bullets, int ox, int oy,
float alpha) const
{
const int lift = int(g_tune.bulletLiftPx);
const uint32_t inten = uint32_t(Clampf(g_tune.fxIntensity, 0.0f, 4.0f) * 256.0f);
// --- трассеры ------------------------------------------------------------
// Длина и цвет штриха приехали вместе со снарядом: у пистолета трассер
// короче и теплее, чем у карабина, и это видно без единой подписи.
for (const Bullet& b : bullets)
{
// Голова — интерполированная позиция кадра, хвост — на tracerLength
// назад по вектору движения, но не дальше точки вылета.
const Vec2 head = Lerp(b.prevPos, b.pos, alpha);
const Vec2 dir = Normalized(b.vel);
const float flown = Distance(b.origin, head);
const float tailLen = std::min(b.tracerLength, flown);
const Vec2 tail = head - dir * tailLen;
const Vec2 hs = ToScreen(head, ox, oy, lift);
const Vec2 ts = ToScreen(tail, ox, oy, lift);
const uint32_t headCol = ScaleColor8(FXC(b.tracerR, b.tracerG, b.tracerB), inten);
fb.AddBeam(ts.x, ts.y, hs.x, hs.y, headCol, ScaleColor8(COL_TRACER_TAIL, inten), 1);
// Само тело пули — яркая точка на голове штриха.
fb.AddGlowDisc(int(hs.x), int(hs.y), 2, headCol);
}
// --- вспышки у дула ------------------------------------------------------
for (int i = 0; i < flashCount; ++i)
{
const FxFlash& f = flashes[i];
const float t = Clampf(f.life / std::max(0.001f, f.maxLife), 0.0f, 1.0f);
const float amp = t * t;
const Vec2 s = ToScreen(f.pos, ox, oy, lift);
const uint32_t c = ScaleColor8(f.color ? f.color : COL_MUZZLE,
uint32_t(amp * float(inten)));
fb.AddGlowDisc(int(s.x), int(s.y), int(f.radiusPx * amp) + 1, c);
// Короткий язык пламени по направлению выстрела.
const Vec2 tip = ToScreen(f.pos + f.dir * f.flamePx, ox, oy, lift);
fb.AddBeam(s.x, s.y, tip.x, tip.y, c, ScaleColor8(c, 128), 1);
}
// --- искры попаданий ------------------------------------------------------
for (int i = 0; i < sparkCount; ++i)
{
const FxSpark& sp = sparks[i];
const float t = Clampf(sp.life / std::max(0.001f, g_tune.sparkTime), 0.0f, 1.0f);
const float amp = t * t * sp.power;
const Vec2 s = ToScreen(sp.pos, ox, oy, lift);
const uint32_t c = ScaleColor8(sp.color, uint32_t(amp * float(inten)));
fb.AddGlowDisc(int(s.x), int(s.y), int(g_tune.sparkRadiusPx * amp) + 1, c);
// Разлёт осколков назад от направления удара — детерминированно по индексу.
const int shards = 4;
for (int k = 0; k < shards; ++k)
{
const float ang = AngleOf(sp.dir) + PI_F + (float(k) - 1.5f) * 0.5f;
const float len = (1.0f - t) * 0.45f;
const Vec2 e = ToScreen(sp.pos + FromAngle(ang) * len, ox, oy, lift);
fb.AddBeam(s.x, s.y, e.x, e.y, c, 0u, 0);
}
}
}
void DrawMeleeArcs(Framebuffer& fb, const Agent* agents, int count, int ox, int oy, float alpha)
{
const int lift = int(g_tune.bladeLiftPx);
const uint32_t inten = uint32_t(Clampf(g_tune.fxIntensity, 0.0f, 4.0f) * 256.0f);
for (int i = 0; i < count; ++i)
{
const Agent& a = agents[i];
if (!a.IsMelee()) continue;
const MeleeWeapon& m = a.melee;
if (m.state == MeleeState::IDLE) continue;
const Vec2 p = Lerp(a.prevPos, a.pos, alpha);
const float half = m.HalfArc();
const float start = WrapPi(m.dir - m.sweepSign * half); // откуда заходит лезвие
if (m.state == MeleeState::WINDUP)
{
// Телеграф: клинок уводится НАЗАД за край сектора и разгорается.
// Игрок обязан успеть прочитать замах — иначе удар бьёт «из ниоткуда»
// и ощущается не как удар, а как случайное списание HP.
const float t = m.Progress();
const float back = WrapPi(start - m.sweepSign * (0.4f * half * t));
const uint32_t c = ScaleColor8(COL_BLADE_COLD, uint32_t(inten * (0.2f + 0.8f * t)));
const Vec2 near_ = ToScreen(p + FromAngle(back) * (m.reach * 0.40f), ox, oy, lift);
const Vec2 far_ = ToScreen(p + FromAngle(back) * (m.reach * 0.95f), ox, oy, lift);
AddWorldArc(fb, p, m.reach * 0.9f, start, back, ox, oy, lift, 0u, c, 0);
fb.AddBeam(near_.x, near_.y, far_.x, far_.y, c, ScaleColor8(c, 64), 0);
fb.AddGlowDisc(int(far_.x), int(far_.y), 1 + int(2.0f * t), c);
continue;
}
// STRIKE и RECOVER рисуются одним серпом: в ударе он растёт до текущего
// угла лезвия, после удара — стоит на месте и гаснет.
float prog = 1.0f;
float fade = 1.0f;
if (m.state == MeleeState::STRIKE)
{
prog = m.Progress();
}
else
{
// Затухание быстрое: след клинка не должен висеть до следующего замаха.
fade = 1.0f - Clampf(m.Progress() * 2.2f, 0.0f, 1.0f);
if (fade <= 0.0f) continue;
}
const float bladeAng = m.BladeAngleAt(prog);
// Хвост — не весь пройденный путь, а его доля: серп, а не кольцо.
const float tailProg = std::max(0.0f, prog - g_tune.bladeTrailFrac);
const float tailAng = m.BladeAngleAt(tailProg);
const uint32_t k = uint32_t(inten * fade);
// Серп набирается слоями концентрических дуг: внешний край раскалён,
// внутренние тускнеют к рукояти. На 480x270 одна линия читается палкой,
// а четыре — взмахом.
constexpr int LAYERS = 4;
for (int l = 0; l < LAYERS; ++l)
{
const float f = float(l) / float(LAYERS - 1);
const float rr = m.reach * Lerpf(0.42f, 1.02f, f);
const uint32_t hot = ScaleColor8(COL_BLADE_EDGE, uint32_t(k * (0.25f + 0.75f * f)));
const uint32_t cold = ScaleColor8(COL_BLADE_TAIL, uint32_t(k * 0.18f * (0.4f + f)));
AddWorldArc(fb, p, rr, tailAng, bladeAng, ox, oy, lift, cold, hot,
(l == LAYERS - 1) ? 1 : 0);
}
// Само лезвие: раскалённая радиальная черта на голове серпа.
const Vec2 inner = ToScreen(p + FromAngle(bladeAng) * (m.reach * 0.38f), ox, oy, lift);
const Vec2 tip = ToScreen(p + FromAngle(bladeAng) * (m.reach * 1.04f), ox, oy, lift);
const uint32_t edge = ScaleColor8(COL_BLADE_EDGE, k);
fb.AddBeam(inner.x, inner.y, tip.x, tip.y, edge, ScaleColor8(COL_BLADE_TAIL, k / 2u), 1);
fb.AddGlowDisc(int(tip.x), int(tip.y), 3, edge);
}
}

75
src/render/fx.h Normal file
View File

@ -0,0 +1,75 @@
#pragma once
// -----------------------------------------------------------------------------
// Визуал стрельбы: трассеры, вспышки у дула, искры попаданий.
// Render-only. Физику пули не трогает вообще — снаряд по-прежнему летит ровно
// по проверенному отрезку, правило «не стрелять сквозь своих» не задето.
//
// Из чего состоит трассер (как это выглядит в CS2, перенесённое на 480x270):
// * вытянутый штрих вдоль вектора движения, а не точка;
// * яркое ядро + тусклый боковой ореол, затухание к хвосту;
// * короткая вспышка у дула в момент выстрела;
// * искра в точке попадания, разная для стены и для цели.
// -----------------------------------------------------------------------------
#include "core/math.h"
#include "render/framebuffer.h"
#include "render/iso.h"
#include "sim/agent.h"
#include "sim/bullet.h"
#include "sim/events.h"
constexpr int FX_FLASH_MAX = 32;
constexpr int FX_SPARK_MAX = 64;
struct FxFlash // вспышка у дула
{
Vec2 pos{};
Vec2 dir{};
float life = 0.0f;
float maxLife = 0.07f; // всё, что описывает вид выстрела, приезжает
float radiusPx = 5.0f; // с оружия стрелявшего: у пистолета вспышка мельче
float flamePx = 0.35f;
uint32_t color = 0;
int owner = 0;
};
struct FxSpark // искра попадания
{
Vec2 pos{};
Vec2 dir{};
float life = 0.0f;
float power = 1.0f;
uint32_t color = 0;
};
struct FxSystem
{
FxFlash flashes[FX_FLASH_MAX];
int flashCount = 0;
FxSpark sparks[FX_SPARK_MAX];
int sparkCount = 0;
uint32_t eventCursor = 0;
// Отдача камеры за кадр: считается здесь, потому что только здесь известно,
// из ЧЕГО стреляли. Потребитель (Dread) забирает и обнуляет.
float shotShake = 0.0f;
void Reset();
// agents нужны ровно для одного: взять визуал выстрела с оружия стрелка.
void Ingest(const CombatEventLog& log, const Agent* agents, int agentCount);
void Update(float frameDt);
// Рисуется ПОСЛЕ сортированной сцены и ДО пост-прохода: эффекты светятся
// поверх геометрии, но подчиняются общей цветокоррекции кадра.
void Draw(Framebuffer& fb, const std::vector<Bullet>& bullets, int ox, int oy, float alpha) const;
};
// Дуга замаха и удара. Читается ПРЯМО из состояния агентов, а не из журнала:
// фаза живёт десятые доли секунды, её видно в каждом кадре, и привязка к живой
// позиции бойца важнее — он в момент удара едет вперёд рывком.
//
// Что рисуется:
// WINDUP — холодная тонкая дуга у дальнего края сектора, разгорается;
// STRIKE — раскалённый серп от начала взмаха до текущего угла лезвия;
// RECOVER — то, что осталось от серпа, гаснет.
void DrawMeleeArcs(Framebuffer& fb, const Agent* agents, int count, int ox, int oy, float alpha);

93
src/render/iso.h Normal file
View File

@ -0,0 +1,93 @@
#pragma once
// -----------------------------------------------------------------------------
// Изометрия существует ТОЛЬКО здесь и в рендере. Симуляция, ИИ и коллизии
// работают в декартовом мире (top-down) и об этом файле не знают.
//
// sx = (wx - wy) * 16
// sy = (wx + wy) * 8
// -----------------------------------------------------------------------------
#include <algorithm>
#include <vector>
#include "core/math.h"
#include "render/framebuffer.h"
#include "tuning.h"
namespace iso
{
inline Vec2 WorldToScreen(Vec2 w)
{
return {(w.x - w.y) * float(TILE_HALF_W), (w.x + w.y) * float(TILE_HALF_H)};
}
inline Vec2 ScreenToWorld(Vec2 s)
{
const float a = s.x / float(TILE_W); // = (wx - wy) / 2
const float b = s.y / float(TILE_H); // = (wx + wy) / 2
return {b + a, b - a};
}
// Ключ сортировки по глубине: чем больше, тем «ближе к камере».
inline float Depth(Vec2 w) { return w.x + w.y; }
// Направление в ЭКРАННЫХ осях -> направление в мире (WASD, спек 5).
inline Vec2 ScreenDirToWorld(Vec2 screenDir)
{
return Normalized(ScreenToWorld(screenDir));
}
// Камера следует за якорем со сглаживанием; смещение округляется до пикселя.
struct Camera
{
Vec2 pos{};
bool snapped = false;
void SnapTo(Vec2 target) { pos = target; snapped = true; }
void Follow(Vec2 target, float dt, float smooth)
{
if (!snapped) { SnapTo(target); return; }
const float k = 1.0f - std::exp(-smooth * dt);
pos = Lerp(pos, target, k);
}
void ScreenOffset(int& ox, int& oy) const
{
const Vec2 c = WorldToScreen(pos);
ox = int(std::lround(INTERNAL_W * 0.5f - c.x));
oy = int(std::lround(INTERNAL_H * 0.5f - c.y));
}
};
// Один элемент списка отрисовки: и стены, и сущности идут в общий список.
struct DrawItem
{
float depth = 0.0f;
const Sprite* spr = nullptr;
int x = 0;
int y = 0;
uint32_t tint = 0;
bool hasTint = false; // явный флаг: RGBA(0,0,0,0) — легальный цвет
bool shaded = false; // рисовать через световую рампу
ShadeQuad quad{};
};
inline void SortByDepth(std::vector<DrawItem>& items)
{
std::stable_sort(items.begin(), items.end(),
[](const DrawItem& a, const DrawItem& b) { return a.depth < b.depth; });
}
inline void FlushDrawList(Framebuffer& fb, const std::vector<DrawItem>& items)
{
for (const DrawItem& it : items)
{
if (!it.spr) continue;
if (it.hasTint) fb.BlitTinted(*it.spr, it.x, it.y, it.tint);
else if (it.shaded) fb.BlitShaded(*it.spr, it.x, it.y, it.quad);
else fb.Blit(*it.spr, it.x, it.y);
}
}
} // namespace iso

282
src/render/lighting.cpp Normal file
View File

@ -0,0 +1,282 @@
#include "render/lighting.h"
namespace
{
inline float Smooth01(float t)
{
if (t <= 0.0f) return 0.0f;
if (t >= 1.0f) return 1.0f;
return t * t * (3.0f - 2.0f * t);
}
// Плавное падение с 1 до 0 на отрезке [lo, hi].
inline float FadeOut(float x, float lo, float hi)
{
if (hi <= lo) return x <= lo ? 1.0f : 0.0f;
return 1.0f - Smooth01((x - lo) / (hi - lo));
}
} // namespace
void Lighting::Reset()
{
for (int i = 0; i < MAP_W * MAP_H; ++i)
{
vis[i] = 0.0f;
mem[i] = 0.0f;
dyn[i] = 0.0f;
tile[i] = 0.0f;
}
for (int i = 0; i < (MAP_W + 1) * (MAP_H + 1); ++i) corner[i] = 0.0f;
flashCount = 0;
eventCursor = 0;
}
void Lighting::Ingest(const CombatEventLog& log, const Agent* agents, int agentCount)
{
// Кольцо: если за кадр событий больше ёмкости, старые честно потеряны.
uint32_t from = eventCursor;
if (log.head > from + COMBAT_EVENT_CAP) from = log.head - COMBAT_EVENT_CAP;
for (uint32_t i = from; i < log.head; ++i)
{
const CombatEvent& e = log.buf[i % COMBAT_EVENT_CAP];
// Параметры вспышки берутся с оружия стрелявшего, если оно известно.
const bool ownerKnown = (e.owner >= 0 && e.owner < agentCount);
const float ownPower = ownerKnown ? agents[e.owner].gun.def.flashPower : g_tune.flashPower;
const float ownRadius = ownerKnown ? agents[e.owner].gun.def.flashRadius : g_tune.flashRadius;
float power = 0.0f;
switch (e.kind)
{
case CombatEventKind::SHOT: power = ownPower; break;
case CombatEventKind::HIT_WALL: power = g_tune.flashPower * 0.45f; break;
case CombatEventKind::HIT_TARGET: power = g_tune.flashPower * 0.35f; break;
case CombatEventKind::HIT_KILL: power = g_tune.flashPower * 0.9f; break;
// Клинок не даёт дульной вспышки, но искры от удара в упор освещают
// не хуже — иначе весь ближний бой идёт в кромешной темноте.
case CombatEventKind::MELEE_SWING: power = g_tune.flashPower * 0.22f; break;
case CombatEventKind::MELEE_HIT: power = g_tune.flashPower * 0.75f; break;
default: break;
}
if (power <= 0.0f) continue;
if (flashCount < LIGHT_FLASH_MAX)
flashes[flashCount++] = LightFlash{e.pos, power, g_tune.flashTime,
(e.kind == CombatEventKind::SHOT)
? ownRadius : g_tune.flashRadius};
}
eventCursor = log.head;
}
void Lighting::Update(const Agent* agents, int count, const Tilemap& map, float dt)
{
static float visRaw[MAP_W * MAP_H];
if (!enabled)
{
// Аварийный тумблер: всё видно, память полная, вспышек нет.
for (int i = 0; i < MAP_W * MAP_H; ++i) { vis[i] = 1.0f; mem[i] = 1.0f; dyn[i] = 0.0f; }
for (int i = 0; i < MAP_W * MAP_H; ++i) tile[i] = 1.0f;
for (int cy = 0; cy <= MAP_H; ++cy)
for (int cx = 0; cx <= MAP_W; ++cx) corner[cy * (MAP_W + 1) + cx] = 1.0f;
return;
}
for (int i = 0; i < MAP_W * MAP_H; ++i) visRaw[i] = 0.0f;
// --- 1. видимость пола от каждого агента ---------------------------------
const float rFull = g_tune.sightFullRadius;
const float rMax = std::max(rFull + 0.01f, g_tune.sightRadius);
const float coneHalf = g_tune.sightConeDeg * 0.5f * DEG2RAD_F;
const float coneSoft = g_tune.sightConeSoftDeg * DEG2RAD_F;
const float personal = g_tune.sightPersonalRadius;
for (int a = 0; a < count; ++a)
{
const Agent& ag = agents[a];
const int x0 = std::max(0, int(std::floor(ag.pos.x - rMax)));
const int x1 = std::min(MAP_W - 1, int(std::floor(ag.pos.x + rMax)));
const int y0 = std::max(0, int(std::floor(ag.pos.y - rMax)));
const int y1 = std::min(MAP_H - 1, int(std::floor(ag.pos.y + rMax)));
for (int ty = y0; ty <= y1; ++ty)
{
for (int tx = x0; tx <= x1; ++tx)
{
if (map.IsWall(tx, ty)) continue; // стены освещаются отдельно
const Vec2 c{tx + 0.5f, ty + 0.5f};
const Vec2 d = c - ag.pos;
const float dist = Length(d);
if (dist > rMax) continue;
const float fd = FadeOut(dist, rFull, rMax);
if (fd <= 0.0f) continue;
// Конус по направлению взгляда + круговой «личный» ореол рядом.
// max, а не сумма: ореол дырявит конус вблизи, но не удваивает свет.
const float ang = std::fabs(AngleDiff(ag.facing, AngleOf(d)));
const float fc = FadeOut(ang, coneHalf, coneHalf + coneSoft);
const float fp = FadeOut(dist, personal * 0.6f, personal);
const float v = fd * std::max(fc, fp);
if (v <= 0.0f) continue;
const int idx = ty * MAP_W + tx;
if (v <= visRaw[idx]) continue; // уже ярче — рейкаст не нужен
if (map.RaycastBlocked(ag.pos, c)) continue; // стена перекрыла обзор
visRaw[idx] = v;
}
}
}
// --- 2. свет протекает в стены ------------------------------------------
// Стену нельзя проверить лучом до её центра: DDA считает попадание в саму
// стену перекрытием. Поэтому стена берёт максимум от соседей.
for (int pass = 0; pass < WALL_LIGHT_PASSES; ++pass)
{
for (int ty = 0; ty < MAP_H; ++ty)
{
for (int tx = 0; tx < MAP_W; ++tx)
{
if (!map.IsWall(tx, ty)) continue;
float best = 0.0f;
for (int oy = -1; oy <= 1; ++oy)
for (int ox = -1; ox <= 1; ++ox)
{
if (ox == 0 && oy == 0) continue;
const int nx = tx + ox, ny = ty + oy;
if (!Tilemap::InBounds(nx, ny)) continue;
best = std::max(best, visRaw[ny * MAP_W + nx]);
}
const int idx = ty * MAP_W + tx;
visRaw[idx] = std::max(visRaw[idx], best * g_tune.fogWallBleed);
}
}
}
// --- 3. временное сглаживание, чтобы свет не щёлкал ----------------------
const float k = 1.0f - std::exp(-g_tune.fogFadeRate * dt);
for (int i = 0; i < MAP_W * MAP_H; ++i)
vis[i] += (visRaw[i] - vis[i]) * k;
// --- 4. динамический свет от вспышек -------------------------------------
for (int i = 0; i < MAP_W * MAP_H; ++i) dyn[i] = 0.0f;
for (int i = 0; i < flashCount;)
{
LightFlash& f = flashes[i];
f.life -= dt;
if (f.life <= 0.0f)
{
flashes[i] = flashes[--flashCount];
continue;
}
const float t = Clampf(f.life / std::max(0.001f, g_tune.flashTime), 0.0f, 1.0f);
const float amp = f.power * t * t; // резкий спад, вспышка короткая
const float rad = f.radius;
const int x0 = std::max(0, int(std::floor(f.pos.x - rad)));
const int x1 = std::min(MAP_W - 1, int(std::floor(f.pos.x + rad)));
const int y0 = std::max(0, int(std::floor(f.pos.y - rad)));
const int y1 = std::min(MAP_H - 1, int(std::floor(f.pos.y + rad)));
for (int ty = y0; ty <= y1; ++ty)
for (int tx = x0; tx <= x1; ++tx)
{
const Vec2 c{tx + 0.5f, ty + 0.5f};
const float dist = Distance(f.pos, c);
if (dist > rad) continue;
const float fall = FadeOut(dist, rad * 0.15f, rad);
if (fall <= 0.0f) continue;
// Вспышка не светит сквозь стены (сама стена подсвечивается).
if (!map.IsWall(tx, ty) && map.RaycastBlocked(f.pos, c)) continue;
const int idx = ty * MAP_W + tx;
dyn[idx] = std::max(dyn[idx], amp * fall);
}
++i;
}
// --- 5. память карты и сборка итогового уровня ---------------------------
for (int i = 0; i < MAP_W * MAP_H; ++i)
{
const float seen = vis[i];
if (seen > 0.02f) mem[i] = std::max(mem[i], g_tune.fogMemoryLevel);
float lvl = std::max(mem[i], seen * g_tune.sightBrightness) + dyn[i];
tile[i] = Clampf(lvl, 0.0f, 1.0f);
}
// --- 6. узловая сетка: узел = среднее смежных тайлов ----------------------
for (int cy = 0; cy <= MAP_H; ++cy)
{
for (int cx = 0; cx <= MAP_W; ++cx)
{
float sum = 0.0f;
int n = 0;
for (int oy = -1; oy <= 0; ++oy)
for (int ox = -1; ox <= 0; ++ox)
{
const int tx = cx + ox, ty = cy + oy;
if (!Tilemap::InBounds(tx, ty)) continue;
sum += tile[ty * MAP_W + tx];
++n;
}
corner[cy * (MAP_W + 1) + cx] = (n > 0) ? (sum / float(n)) : 0.0f;
}
}
}
float Lighting::LightAt(Vec2 world) const
{
const float fx = Clampf(world.x, 0.0f, float(MAP_W) - 0.001f);
const float fy = Clampf(world.y, 0.0f, float(MAP_H) - 0.001f);
const int cx = int(fx);
const int cy = int(fy);
const float u = fx - float(cx);
const float v = fy - float(cy);
const int stride = MAP_W + 1;
const float c00 = corner[cy * stride + cx];
const float c10 = corner[cy * stride + cx + 1];
const float c01 = corner[(cy + 1) * stride + cx];
const float c11 = corner[(cy + 1) * stride + cx + 1];
return c00 * (1 - u) * (1 - v) + c10 * u * (1 - v) + c01 * (1 - u) * v + c11 * u * v;
}
float Lighting::MaxCornerOf(int tx, int ty) const
{
if (!Tilemap::InBounds(tx, ty)) return 0.0f;
const int stride = MAP_W + 1;
const float c00 = corner[ty * stride + tx];
const float c10 = corner[ty * stride + tx + 1];
const float c01 = corner[(ty + 1) * stride + tx];
const float c11 = corner[(ty + 1) * stride + tx + 1];
return std::max(std::max(c00, c10), std::max(c01, c11));
}
ShadeQuad Lighting::QuadFor(int tx, int ty, int clampRow) const
{
const int stride = MAP_W + 1;
const float c00 = corner[ty * stride + tx]; // верх ромба, спрайт (16,0)
const float c10 = corner[ty * stride + tx + 1]; // право, спрайт (32,8)
const float c01 = corner[(ty + 1) * stride + tx]; // лево, спрайт (0,8)
const float c11 = corner[(ty + 1) * stride + tx + 1]; // низ, спрайт (16,16)
const float Bu = c10 - c00;
const float Bv = c01 - c00;
const float K = c00 - c10 - c01 + c11;
ShadeQuad q;
q.a = c00 - (Bu - Bv) * 0.5f - K * 0.25f;
q.b = (Bu - Bv + K) / float(TILE_W);
q.c = (Bu + Bv) / float(TILE_H);
q.e = -K / float(TILE_W * TILE_W);
q.f = K / float(TILE_H * TILE_H);
q.clampRow = clampRow;
return q;
}

81
src/render/lighting.h Normal file
View File

@ -0,0 +1,81 @@
#pragma once
// -----------------------------------------------------------------------------
// Свет и туман войны. Render-only: симуляция об этом модуле не знает вообще,
// ИИ и стрельба работают как работали (спек 6 не тронут).
//
// Почему свет НЕ делается пост-проходом по пикселям:
// спрайт стены имеет привязку ay = TILE_HALF_H + TILE_H = 24, то есть верхняя
// грань поднята на 16 px над ромбом своего тайла. Экранный пиксель крыши,
// прогнанный обратно через iso::ScreenToWorld, даёт ScreenToWorld(0,-16) =
// ровно (-1,-1) тайла, то есть СОСЕДНИЙ тайл на северо-запад. Пост-проход
// осветил бы крыши чужой видимостью: чёрные крыши на освещённых стенах.
// Поэтому свет вплавляется в блит спрайта: спрайт целиком светится от СВОЕГО
// тайла, и артефакт исчезает конструктивно.
//
// Три состояния области:
// никогда не видели -> ступень 0 рампы (чистый чёрный, тайл не рисуется);
// видели раньше -> тускло и холодно, сущности не показываются;
// видно сейчас -> нормальная яркость;
// вспышка выстрела -> верх рампы, тёплый пересвет.
// -----------------------------------------------------------------------------
#include "core/math.h"
#include "render/framebuffer.h"
#include "sim/agent.h"
#include "sim/events.h"
#include "tuning.h"
#include "world/tilemap.h"
// Свет протекает вглубь блока стен: разделитель y=13..15 толщиной 3 тайла иначе
// остаётся чёрным изнутри, и его южная грань висит в пустоте.
constexpr int WALL_LIGHT_PASSES = 3;
constexpr int LIGHT_FLASH_MAX = 24;
struct LightFlash
{
Vec2 pos{};
float power = 0.0f;
float life = 0.0f;
float radius = 4.0f; // свой у каждого ствола: пистолет светит мельче
};
struct Lighting
{
float vis[MAP_W * MAP_H] = {}; // сглаженная текущая видимость
float mem[MAP_W * MAP_H] = {}; // память карты
float dyn[MAP_W * MAP_H] = {}; // динамический свет (вспышки)
float tile[MAP_W * MAP_H] = {}; // итоговый уровень на тайл
float corner[(MAP_W + 1) * (MAP_H + 1)] = {}; // узловая сетка для билинейки
LightFlash flashes[LIGHT_FLASH_MAX];
int flashCount = 0;
uint32_t eventCursor = 0;
bool enabled = true; // F5 — аварийно выключить туман целиком
void Reset();
// Забрать вспышки из журнала боя (то, что накопилось за кадр).
// agents нужны, чтобы взять параметры вспышки с оружия стрелявшего:
// у пистолета она мельче и холоднее, чем у карабина.
void Ingest(const CombatEventLog& log, const Agent* agents, int agentCount);
// Пересчитать поле света. agents/count — только чтение.
void Update(const Agent* agents, int count, const Tilemap& map, float dt);
// Уровень света в мировой точке (для сущностей: они светятся от своей
// позиции, а не от того, что под ними на экране).
float LightAt(Vec2 world) const;
// Патч света для тайла: точная билинейка по четырём углам его ромба.
ShadeQuad QuadFor(int tx, int ty, int clampRow) const;
// Максимум по четырём углам: если он ниже ступени 1 рампы, тайл рисовать
// не нужно — результат пиксель-в-пиксель совпадёт с цветом очистки.
float MaxCornerOf(int tx, int ty) const;
float TileLevel(int tx, int ty) const
{
if (!Tilemap::InBounds(tx, ty)) return 0.0f;
return tile[ty * MAP_W + tx];
}
};

13
src/render/noise.h Normal file
View File

@ -0,0 +1,13 @@
#pragma once
// -----------------------------------------------------------------------------
// Псевдослучайность рендера.
//
// Сами функции живут в core/hash.h: они понадобились и симуляции (разброс
// выстрела считается детерминированным хешем от номера выстрела, а не
// генератором). Здесь остался только вход для рендера, чтобы не расползались
// включения: зерно, дрожание, пепел и вспышки берут «случайность» отсюда.
//
// Состояния нет ни у кого: значение зависит только от аргументов (координата,
// номер кадра), общего генератора нет и синхронизировать нечего.
// -----------------------------------------------------------------------------
#include "core/hash.h"

279
src/render/post.cpp Normal file
View File

@ -0,0 +1,279 @@
#include "render/post.h"
#include <cmath>
#include "render/noise.h"
#include "tuning.h"
// -----------------------------------------------------------------------------
// Dread
// -----------------------------------------------------------------------------
void Dread::Reset()
{
level = 0.0f;
heartPhase = 0.0f;
shake = 0.0f;
pulse = 0.0f;
}
void Dread::Update(const DreadInput& in, float frameDt)
{
// Чего боимся: близкая мишень, близкая НЕВИДИМАЯ мишень (страшнее всего),
// толпа, и отсутствие огневых линий — отряд слеп и беспомощен.
const float r = std::max(0.5f, g_tune.dreadRadius);
const float near_ = Clampf(1.0f - in.nearestTargetDist / r, 0.0f, 1.0f);
const float unseen = Clampf(1.0f - in.nearestUnseenDist / r, 0.0f, 1.0f);
const float crowd = Clampf(float(in.targetsNear) / 4.0f, 0.0f, 1.0f);
const float blind = 1.0f - Clampf(float(in.agentsWithLane) / float(NUM_AGENTS), 0.0f, 1.0f);
float target = Clampf(0.45f * near_ + 0.75f * unseen + 0.25f * crowd + 0.30f * blind,
0.0f, 1.0f);
if (in.friendlyDelta > 0) target = 1.0f; // попали по своему — максимум
// Асимметрия: страх набегает быстро, отпускает медленно.
const float rate = (target > level) ? g_tune.dreadAttack : g_tune.dreadRelease;
level += (target - level) * (1.0f - std::exp(-rate * frameDt));
level = Clampf(level, 0.0f, 1.0f);
// Сердцебиение «туп-туп»: частота растёт с напряжением.
const float bpm = Lerpf(g_tune.heartBpmCalm, g_tune.heartBpmTense, level);
heartPhase += frameDt * (bpm / 60.0f);
if (heartPhase >= 1.0f) heartPhase -= std::floor(heartPhase);
// Отдача от выстрелов и вспышка тона.
if (in.shotsDelta > 0)
{
// Отдача пришла посчитанной по стволам (render/fx.cpp): пистолет трясёт
// кадр слабее карабина. Если её почему-то нет — общий множитель.
const float kick = (in.shotShake > 0.0f) ? in.shotShake
: g_tune.shakePerShot * float(in.shotsDelta);
shake = std::min(g_tune.shakeMax, shake + kick);
pulse = std::min(1.0f, pulse + 0.25f * float(in.shotsDelta));
}
if (in.killsDelta > 0) shake = std::min(g_tune.shakeMax, shake + g_tune.shakePerShot * 2.0f);
// Удар клинком трясёт кадр сильнее выстрела: он всегда в упор, и вес
// попадания — единственное, чем ближний бой отличается от размахивания.
if (in.meleeHitsDelta > 0)
{
shake = std::min(g_tune.shakeMax, shake + g_tune.meleeShake * float(in.meleeHitsDelta));
pulse = std::min(1.0f, pulse + 0.35f * float(in.meleeHitsDelta));
}
shake *= std::exp(-g_tune.shakeDecay * frameDt);
pulse *= std::exp(-6.0f * frameDt);
}
void Dread::CameraOffset(uint32_t frame, int& dx, int& dy) const
{
// Отдача — детерминированный хеш от номера кадра, без общего генератора.
const float amp = shake;
if (amp < 0.05f) { dx = 0; dy = 0; return; }
dx = int(std::lround(HashSigned(Hash2(frame, 11u)) * amp));
dy = int(std::lround(HashSigned(Hash2(frame, 29u)) * amp));
}
// -----------------------------------------------------------------------------
// PostFx
// -----------------------------------------------------------------------------
namespace
{
// Гладкая ступенька для интерполяции шума.
inline float SmoothStep01(float t) { return t * t * (3.0f - 2.0f * t); }
// Значение шума в точке решётки с билинейной интерполяцией.
float LatticeValue(float x, float y)
{
const float fx = std::floor(x);
const float fy = std::floor(y);
const int xi = int(fx);
const int yi = int(fy);
const float tx = SmoothStep01(x - fx);
const float ty = SmoothStep01(y - fy);
const float a = Hash01(Hash2(uint32_t(xi + 8192), uint32_t(yi + 8192)));
const float b = Hash01(Hash2(uint32_t(xi + 8193), uint32_t(yi + 8192)));
const float c = Hash01(Hash2(uint32_t(xi + 8192), uint32_t(yi + 8193)));
const float d = Hash01(Hash2(uint32_t(xi + 8193), uint32_t(yi + 8193)));
return Lerpf(Lerpf(a, b, tx), Lerpf(c, d, tx), ty);
}
} // namespace
void PostFx::Init(int w, int h)
{
vigD.assign(size_t(w) * size_t(h), 0);
hazeW = w / HAZE_CELL + 2;
hazeH = h / HAZE_CELL + 2;
hazeGrid.assign(size_t(hazeW) * size_t(hazeH), 0);
const float cx = float(w) * 0.5f;
const float cy = float(h) * 0.5f;
const float norm = 1.0f / std::sqrt(cx * cx + cy * cy);
for (int y = 0; y < h; ++y)
{
for (int x = 0; x < w; ++x)
{
const float dx = (float(x) - cx);
const float dy = (float(y) - cy) * 1.15f; // экран шире, чем выше
const float d = std::sqrt(dx * dx + dy * dy) * norm; // 0 в центре, ~1 в углу
vigD[size_t(y) * w + x] = uint8_t(Clampf(d, 0.0f, 1.0f) * 255.0f);
}
}
}
// Два октава шума по узлам сетки. Клубы сносит вбок и медленно перемешивает —
// это и есть «пыль в воздухе»: не текстура и не частицы, а неоднородность
// объёма, которую видно только там, где есть чему подсвечиваться.
void PostFx::BuildHaze(float time)
{
const float scale = 1.0f / std::max(0.05f, g_tune.hazeScale);
const float drift = time * g_tune.hazeDrift;
for (int gy = 0; gy < hazeH; ++gy)
{
for (int gx = 0; gx < hazeW; ++gx)
{
const float x = float(gx) * 0.34f * scale;
const float y = float(gy) * 0.34f * scale;
// Второй октав идёт против сноса — клубы не «едут доской».
const float v = 0.62f * LatticeValue(x + drift, y + drift * 0.35f) +
0.38f * LatticeValue(x * 2.7f - drift * 1.6f, y * 2.7f + drift * 0.8f);
hazeGrid[size_t(gy) * hazeW + gx] = uint8_t(Clampf(v, 0.0f, 1.0f) * 255.0f);
}
}
}
void PostFx::Apply(Framebuffer& fb, const Dread& dread, float time)
{
++frame;
if (!enabled) return;
if (vigD.size() != fb.px.size()) return;
const bool hazeOn = (g_tune.hazeAmount > 0.001f) &&
(hazeGrid.size() == size_t(hazeW) * size_t(hazeH)) && hazeW > 0;
if (hazeOn) BuildHaze(time);
// Сколько уровней яркости добавляет дымка на пике.
const int hazeAmp = int(Clampf(g_tune.hazeAmount, 0.0f, 2.0f) * 44.0f);
const int hazeGate = std::max(1, int(g_tune.hazeGate));
// --- таблицы кадра -------------------------------------------------------
// Виньетка: в напряжении темнее и «сужается».
const float vigStart = Lerpf(g_tune.vigStartCalm, g_tune.vigStartTense, dread.level);
const float vigDepth = Lerpf(g_tune.vigDepthCalm, g_tune.vigDepthTense, dread.level);
// Сердцебиение: два коротких удара за цикл, слышно только в напряжении.
const float hp = dread.heartPhase;
float beat = 0.0f;
if (hp < 0.10f) beat = 1.0f - hp / 0.10f;
else if (hp < 0.22f) beat = 0.55f * (1.0f - (hp - 0.10f) / 0.12f);
const float beatAmp = beat * dread.level * g_tune.heartAmp;
uint32_t vigLut[256];
for (int i = 0; i < 256; ++i)
{
const float d = float(i) / 255.0f;
float k = 1.0f - vigDepth * Clampf((d - vigStart) / std::max(0.001f, 1.0f - vigStart),
0.0f, 1.0f);
k *= (1.0f - beatAmp);
k *= (1.0f + dread.pulse * g_tune.shotFlash);
vigLut[i] = uint32_t(Clampf(k, 0.0f, 3.0f) * 256.0f);
}
// Зерно сильнее в тенях: в свету плёнка чистая, в темноте шумит.
const float grainBase = Lerpf(g_tune.grainCalm, g_tune.grainTense, dread.level);
int grainAmp[256];
for (int i = 0; i < 256; ++i)
{
// Не выше 127: индекс sat[] равен 128 + канал + шум и обязан остаться
// внутри 512, иначе в Release пойдут случайные яркие точки.
const int v = int(grainBase * (1.0f - float(i) / 320.0f));
grainAmp[i] = v < 0 ? 0 : (v > 127 ? 127 : v);
}
// Насыщение: индекс 128 + значение + шум, диапазон [1, 510] при шуме +-127.
static uint8_t sat[512];
static bool satReady = false;
if (!satReady)
{
for (int i = 0; i < 512; ++i)
{
const int v = i - 128;
sat[i] = uint8_t(v < 0 ? 0 : (v > 255 ? 255 : v));
}
satReady = true;
}
// --- один слитый проход по пикселям --------------------------------------
const int w = fb.w;
const int h = fb.h;
uint32_t* p = fb.px.data();
const uint8_t* vg = vigD.data();
for (int y = 0; y < h; ++y)
{
for (int x = 0; x < w; ++x)
{
const size_t i = size_t(y) * w + x;
uint32_t c = p[i];
const uint32_t r = c & 0xFFu;
const uint32_t g = (c >> 8) & 0xFFu;
const uint32_t b = (c >> 16) & 0xFFu;
const uint32_t k = vigLut[vg[i]];
uint32_t rr = (r * k) >> 8;
uint32_t gg = (g * k) >> 8;
uint32_t bb = (b * k) >> 8;
if (rr > 255u) rr = 255u;
if (gg > 255u) gg = 255u;
if (bb > 255u) bb = 255u;
uint32_t luma = (rr * 77u + gg * 151u + bb * 28u) >> 8;
// --- дымка ---------------------------------------------------------
// Пыль подсвечивается тем, что вокруг. В неразведанной черноте её
// быть не может: иначе туман войны превратился бы в серую простыню.
if (hazeOn && luma > 0u)
{
const int gx = x >> 3;
const int gy = y >> 3;
const int fx = (x & 7) * 32;
const int fy = (y & 7) * 32;
const uint8_t* row0 = &hazeGrid[size_t(gy) * hazeW + gx];
const uint8_t* row1 = row0 + hazeW;
const int top = int(row0[0]) * (256 - fx) + int(row0[1]) * fx;
const int bot = int(row1[0]) * (256 - fx) + int(row1[1]) * fx;
const int v = (top * (256 - fy) + bot * fy) >> 16; // 0..255
int add = (hazeAmp * v) >> 8;
if (int(luma) < hazeGate) add = add * int(luma) / hazeGate;
if (add > 0)
{
rr = std::min(255u, rr + uint32_t(add * 7 / 8));
gg = std::min(255u, gg + uint32_t(add * 15 / 16));
bb = std::min(255u, bb + uint32_t(add));
luma = (rr * 77u + gg * 151u + bb * 28u) >> 8;
}
}
const int amp = grainAmp[luma & 0xFFu];
if (amp > 0)
{
const int n = int(HashSigned(Hash3(uint32_t(x), uint32_t(y), frame)) * float(amp));
rr = sat[128 + int(rr) + n];
gg = sat[128 + int(gg) + n];
bb = sat[128 + int(bb) + n];
}
p[i] = 0xFF000000u | rr | (gg << 8) | (bb << 16);
}
}
}

67
src/render/post.h Normal file
View File

@ -0,0 +1,67 @@
#pragma once
// -----------------------------------------------------------------------------
// Настроение кадра: напряжение (Dread) и один слитый пост-проход по пикселям.
//
// Пост-проход НЕ занимается светом — свет уже вплавлен на блите спрайтов
// (см. render/lighting.h, почему иначе нельзя). Здесь только виньетка, зерно
// и тон, то есть то, что честно считается в экранном пространстве.
// -----------------------------------------------------------------------------
#include <cstdint>
#include <vector>
#include "core/math.h"
#include "render/framebuffer.h"
// Что кадр знает о происходящем — собирается из const Game& в RenderGame.
struct DreadInput
{
float nearestTargetDist = 99.0f; // до ближайшей живой мишени
float nearestUnseenDist = 99.0f; // до ближайшей мишени, которую НЕ видно
int targetsNear = 0; // сколько мишеней рядом
int agentsWithLane = 0; // сколько агентов при линии огня
int shotsDelta = 0; // выстрелов за кадр (дельта, не флаг)
float shotShake = 0.0f; // суммарная отдача от них: у каждого ствола своя
int killsDelta = 0;
int friendlyDelta = 0;
int meleeHitsDelta = 0; // ударов клинком за кадр
};
// Напряжение: медленно отпускает, быстро нарастает. Кормит виньетку, зерно,
// сердцебиение и тряску камеры.
struct Dread
{
float level = 0.0f; // 0..1
float heartPhase = 0.0f;
float shake = 0.0f; // текущая амплитуда отдачи, в пикселях
float pulse = 0.0f; // короткий выброс тона на выстрел
void Reset();
void Update(const DreadInput& in, float frameDt);
// Смещение камеры от отдачи и дыхания, в целых пикселях.
void CameraOffset(uint32_t frame, int& dx, int& dy) const;
};
// Ячейка сетки дымки в пикселях кадра. 8 — компромисс: клубы получаются
// достаточно крупными, чтобы читаться как объём, а сетка остаётся крошечной
// (62x35 узлов), и её пересчёт каждый кадр ничего не стоит.
constexpr int HAZE_CELL = 8;
struct PostFx
{
std::vector<uint8_t> vigD; // статичное поле виньетки, строится один раз
// Дымка: поле шума по узлам сетки, между узлами — билинейка на лету.
// Полноразмерное поле считать незачем: клубы всё равно низкочастотные.
std::vector<uint8_t> hazeGrid;
int hazeW = 0;
int hazeH = 0;
uint32_t frame = 0;
bool enabled = true;
void Init(int w, int h);
void Apply(Framebuffer& fb, const Dread& dread, float time);
private:
void BuildHaze(float time);
};

101
src/render/scene.cpp Normal file
View File

@ -0,0 +1,101 @@
#include "render/scene.h"
namespace
{
// Запас за краями кадра, чтобы не отсекать спрайты, торчащие внутрь.
constexpr int CULL_MARGIN_X = TILE_W;
constexpr int CULL_MARGIN_Y = WALL_H + TILE_H;
inline bool OffScreen(int sx, int sy)
{
return sx < -CULL_MARGIN_X || sx > INTERNAL_W + CULL_MARGIN_X ||
sy < -CULL_MARGIN_Y || sy > INTERNAL_H + CULL_MARGIN_Y;
}
} // namespace
void Scene::Begin(const iso::Camera& cam)
{
items.clear();
cam.ScreenOffset(ox, oy);
}
void Scene::Add(const Sprite* spr, Vec2 world, int pixelLift, float depthBias)
{
const Vec2 s = iso::WorldToScreen(world);
const int sx = int(std::lround(s.x)) + ox;
const int sy = int(std::lround(s.y)) + oy - pixelLift;
if (OffScreen(sx, sy)) return;
iso::DrawItem it;
it.depth = iso::Depth(world) + depthBias;
it.spr = spr;
it.x = sx;
it.y = sy;
items.push_back(it);
}
void Scene::AddShaded(const Sprite* spr, Vec2 world, const ShadeQuad& quad, int pixelLift,
float depthBias)
{
const Vec2 s = iso::WorldToScreen(world);
const int sx = int(std::lround(s.x)) + ox;
const int sy = int(std::lround(s.y)) + oy - pixelLift;
if (OffScreen(sx, sy)) return;
iso::DrawItem it;
it.depth = iso::Depth(world) + depthBias;
it.spr = spr;
it.x = sx;
it.y = sy;
it.shaded = true;
it.quad = quad;
items.push_back(it);
}
void Scene::Flush(Framebuffer& fb)
{
iso::SortByDepth(items);
iso::FlushDrawList(fb, items);
}
// Ниже этого уровня рампа даёт ступень 0, то есть чистый чёрный.
static const float DARK_CUTOFF = 0.5f / float(RAMP_LEVELS - 1);
void DrawFloor(Framebuffer& fb, const SpriteSet& sprites, const Tilemap& map, int ox, int oy,
const Lighting& light, const BloodField* blood)
{
for (int ty = 0; ty < MAP_H; ++ty)
{
for (int tx = 0; tx < MAP_W; ++tx)
{
if (map.IsWall(tx, ty)) continue; // под стеной пол не виден
if (light.MaxCornerOf(tx, ty) < DARK_CUTOFF) continue;
const Vec2 c = iso::WorldToScreen({tx + 0.5f, ty + 0.5f});
const int sx = int(std::lround(c.x)) + ox;
const int sy = int(std::lround(c.y)) + oy;
if (OffScreen(sx, sy)) continue;
const Sprite* stained = blood ? blood->At(tx, ty) : nullptr;
const Sprite& s = stained ? *stained : sprites.FloorTile(tx, ty);
fb.BlitShaded(s, sx, sy, light.QuadFor(tx, ty, TILE_H - 1));
}
}
}
void CollectWalls(Scene& scene, const SpriteSet& sprites, const Tilemap& map,
const Lighting& light)
{
for (int ty = 0; ty < MAP_H; ++ty)
for (int tx = 0; tx < MAP_W; ++tx)
{
if (!map.IsWall(tx, ty)) continue;
if (light.MaxCornerOf(tx, ty) < DARK_CUTOFF) continue;
// clampRow = TILE_H-1: вертикальные грани светятся как нижняя кромка
// крыши, а не как экстраполяция патча на 32 строки вниз.
scene.AddShaded(&sprites.wall, {tx + 0.5f, ty + 0.5f},
light.QuadFor(tx, ty, TILE_H - 1));
}
}

46
src/render/scene.h Normal file
View File

@ -0,0 +1,46 @@
#pragma once
// -----------------------------------------------------------------------------
// Сборка кадра: сплошной проход по полу, затем общий список стен и сущностей,
// отсортированный по глубине (wx + wy).
// -----------------------------------------------------------------------------
#include <vector>
#include "render/blood.h"
#include "render/framebuffer.h"
#include "render/iso.h"
#include "render/sprites.h"
#include "render/lighting.h"
#include "world/tilemap.h"
struct Scene
{
std::vector<iso::DrawItem> items;
int ox = 0; // смещение камеры в пикселях (целое)
int oy = 0;
void Begin(const iso::Camera& cam);
// world — мировая позиция «подошвы» сущности;
// pixelLift — подъём спрайта по экрану (пули летят не по земле);
// depthBias — ручная правка ключа сортировки.
void Add(const Sprite* spr, Vec2 world, int pixelLift = 0, float depthBias = 0.0f);
// Тот же элемент, но с патчем света.
void AddShaded(const Sprite* spr, Vec2 world, const ShadeQuad& quad, int pixelLift = 0,
float depthBias = 0.0f);
void Flush(Framebuffer& fb);
};
// Пол — первым сплошным проходом, в список сортировки не попадает.
// Полностью тёмные тайлы пропускаются: ступень 0 рампы совпадает с цветом
// очистки, поэтому результат пиксель-в-пиксель тот же, а рисовать дешевле.
//
// blood == nullptr — крови нет. Иначе у запачканных тайлов берётся запечённая
// копия ромба. Стоимость кадра от этого не меняется: тот же один блит на тайл,
// сколько бы крови ни накопилось (render/blood.h).
void DrawFloor(Framebuffer& fb, const SpriteSet& sprites, const Tilemap& map, int ox, int oy,
const Lighting& light, const BloodField* blood);
// Все WALL-тайлы уходят в общий список вместе с сущностями.
void CollectWalls(Scene& scene, const SpriteSet& sprites, const Tilemap& map,
const Lighting& light);

202
src/render/sprites.cpp Normal file
View File

@ -0,0 +1,202 @@
#include "render/sprites.h"
#include "core/math.h"
namespace
{
// Занятая часть строки y для ромба 32x16.
void DiamondRow(int y, int& x0, int& x1)
{
const int k = (y < TILE_HALF_H) ? y : (TILE_H - 1 - y);
const int width = 4 * (k + 1);
x0 = TILE_HALF_W - width / 2;
x1 = x0 + width;
}
// Обводка по силуэту: любой непрозрачный пиксель, у которого есть прозрачный
// 4-сосед (или край спрайта), перекрашивается в outline.
void OutlineSilhouette(Sprite& s, uint32_t outline)
{
Sprite copy = s;
for (int y = 0; y < s.h; ++y)
{
for (int x = 0; x < s.w; ++x)
{
if (AlphaOf(copy.Get(x, y)) == 0) continue;
const bool border =
x == 0 || y == 0 || x == s.w - 1 || y == s.h - 1 ||
AlphaOf(copy.Get(x - 1, y)) == 0 || AlphaOf(copy.Get(x + 1, y)) == 0 ||
AlphaOf(copy.Get(x, y - 1)) == 0 || AlphaOf(copy.Get(x, y + 1)) == 0;
if (border) s.Set(x, y, outline);
}
}
}
Sprite MakeFloor(uint32_t base)
{
Sprite s;
s.Init(TILE_W, TILE_H, 0);
s.ax = TILE_HALF_W;
s.ay = TILE_HALF_H;
const uint32_t edge = ShadeColor(base, 0.78f);
const uint32_t fleck = ShadeColor(base, 1.18f);
for (int y = 0; y < TILE_H; ++y)
{
int x0 = 0, x1 = 0;
DiamondRow(y, x0, x1);
for (int x = x0; x < x1; ++x)
{
uint32_t c = base;
if (x < x0 + 2 || x >= x1 - 2) c = edge; // рант тайла
else if (((x * 7 + y * 13) % 23) == 0) c = fleck; // «металлическая» рябь
s.Set(x, y, c);
}
}
return s;
}
Sprite MakeWall(uint32_t top)
{
Sprite s;
s.Init(TILE_W, WALL_H, 0);
s.ax = TILE_HALF_W;
s.ay = TILE_HALF_H + TILE_H; // низ блока садится на ромб пола
const uint32_t left = ShadeColor(top, 0.62f);
const uint32_t right = ShadeColor(top, 0.80f);
// верхняя грань
for (int y = 0; y < TILE_H; ++y)
{
int x0 = 0, x1 = 0;
DiamondRow(y, x0, x1);
for (int x = x0; x < x1; ++x) s.Set(x, y, top);
}
// вертикальные грани: от нижней кромки ромба вниз на высоту тайла
for (int x = 0; x < TILE_W; ++x)
{
int bottom = -1;
for (int y = 0; y < TILE_H; ++y)
if (AlphaOf(s.Get(x, y)) != 0) bottom = y;
if (bottom < 0) continue;
const uint32_t side = (x < TILE_HALF_W) ? left : right;
for (int y = bottom + 1; y <= bottom + TILE_H && y < WALL_H; ++y)
s.Set(x, y, side);
}
OutlineSilhouette(s, ShadeColor(top, 0.35f));
return s;
}
// Капсула с обводкой и бликом.
Sprite MakeCapsule(int w, int h, float radius, uint32_t body)
{
Sprite s;
s.Init(w, h, 0);
s.ax = w / 2;
s.ay = h - 2; // «ноги» чуть выше нижнего края
const uint32_t outline = ShadeColor(body, 0.35f);
const uint32_t hilite = ShadeColor(body, 1.35f);
const uint32_t shadow = ShadeColor(body, 0.72f);
const float cx = (w - 1) * 0.5f;
const Vec2 a{cx, radius - 0.5f};
const Vec2 b{cx, h - radius - 0.5f};
for (int y = 0; y < h; ++y)
{
for (int x = 0; x < w; ++x)
{
const float d = DistancePointToSegment({float(x), float(y)}, a, b);
if (d > radius) continue;
if (d > radius - 1.0f) { s.Set(x, y, outline); continue; }
uint32_t c = body;
if (float(x) < cx - 0.5f && y < h / 2) c = hilite;
else if (float(x) > cx + 1.0f) c = shadow;
s.Set(x, y, c);
}
}
return s;
}
// Боец ближнего боя: та же капсула, но шире в плечах и с клинком у бедра.
// На 480x270 класс обязан читаться силуэтом — цвет уже занят номером агента.
Sprite MakeMeleeCapsule(int w, int h, float radius, uint32_t body)
{
Sprite s = MakeCapsule(w, h, radius, body);
const uint32_t steel = RGBA(196, 206, 222);
const uint32_t edge = RGBA(240, 246, 255);
// Клинок: короткая вертикаль вдоль правого края силуэта, с бликом.
const int bx = w - 2;
for (int y = 2; y < h - 4; ++y)
{
s.Set(bx, y, steel);
if (y < h / 2) s.Set(bx, y, edge);
}
// Гарда, чтобы клинок не читался как случайная полоска.
s.Set(bx - 1, h - 5, steel);
s.Set(bx, h - 5, steel);
return s;
}
// Стрелок с пистолетом: та же капсула, но уже в плечах и с коротким стволом
// у пояса. Класс оружия обязан читаться силуэтом — цвет уже занят номером.
Sprite MakePistolCapsule(int w, int h, float radius, uint32_t body)
{
Sprite s = MakeCapsule(w, h, radius, body);
const uint32_t steel = RGBA(150, 156, 168);
const uint32_t dark = RGBA(70, 74, 84);
// Коротышка-ствол: две пиксельных полоски у бедра, с рукоятью вниз.
const int bx = w - 2;
const int by = h - 7;
s.Set(bx, by, steel);
s.Set(bx - 1, by, steel);
s.Set(bx, by + 1, dark);
s.Set(bx, by + 2, dark);
return s;
}
} // namespace
uint32_t ShadeColor(uint32_t c, float k)
{
const float r = Clampf(float(c & 0xFF) * k, 0.0f, 255.0f);
const float g = Clampf(float((c >> 8) & 0xFF) * k, 0.0f, 255.0f);
const float b = Clampf(float((c >> 16) & 0xFF) * k, 0.0f, 255.0f);
return RGBA(uint8_t(r), uint8_t(g), uint8_t(b), AlphaOf(c));
}
void SpriteSet::Generate()
{
floorA = MakeFloor(RGBA(50, 54, 62));
floorB = MakeFloor(RGBA(42, 46, 53));
wall = MakeWall(RGBA(78, 84, 96));
// 5 различимых цветов — по одному на агента
static const uint32_t agentCols[NUM_AGENTS] = {
RGBA(80, 200, 220),
RGBA(232, 176, 72),
RGBA(116, 204, 116),
RGBA(212, 116, 200),
RGBA(116, 148, 240),
};
for (int i = 0; i < NUM_AGENTS; ++i)
{
agent[i] = MakeCapsule(10, 14, 4.0f, agentCols[i]);
melee[i] = MakeMeleeCapsule(12, 15, 4.6f, agentCols[i]);
pistol[i] = MakePistolCapsule(9, 13, 3.6f, agentCols[i]);
}
target = MakeCapsule(12, 16, 5.0f, RGBA(120, 44, 44));
}

31
src/render/sprites.h Normal file
View File

@ -0,0 +1,31 @@
#pragma once
// -----------------------------------------------------------------------------
// Процедурная генерация всех спрайтов при старте. Файлов с ассетами нет.
// -----------------------------------------------------------------------------
#include "render/framebuffer.h"
#include "tuning.h"
struct SpriteSet
{
Sprite floorA; // шахматка пола, вариант 1
Sprite floorB; // вариант 2
Sprite wall; // блок 32x32
Sprite agent[NUM_AGENTS]; // стрелок: по цвету на агента
Sprite melee[NUM_AGENTS]; // тот же цвет, но силуэт с клинком
Sprite target;
// Спрайта пули нет: снаряд рисуется светящимся трассером (render/fx.cpp).
Sprite pistol[NUM_AGENTS]; // тот же цвет, но силуэт с коротким стволом
void Generate();
// Какой ромб пола лежит на тайле. Одно место на весь рендер: запечённая
// кровь копирует ИМЕННО его, иначе шахматка поедет (render/blood.h).
const Sprite& FloorTile(int tx, int ty) const
{
return ((tx + ty) & 1) ? floorA : floorB;
}
};
// Затемнить/осветлить цвет (множитель по RGB, альфа сохраняется).
uint32_t ShadeColor(uint32_t c, float k);

181
src/render/view.cpp Normal file
View File

@ -0,0 +1,181 @@
#include "render/view.h"
#include "game.h"
#include "render/noise.h"
namespace
{
constexpr uint32_t COL_BG = RGBA(0, 0, 0); // ступень 0 рампы == цвет очистки
}
void View::Init()
{
post.Init(INTERNAL_W, INTERNAL_H);
RefreshRamp();
}
void View::RefreshRamp()
{
if (rampGamma == g_tune.lightGamma && rampGain == g_tune.lightMaxGain) return;
rampGamma = g_tune.lightGamma;
rampGain = g_tune.lightMaxGain;
BuildShadeRamp(rampGamma, rampGain);
}
void View::Reset(const Game& game)
{
lighting.Reset();
fx.Reset();
dread.Reset();
// Кровь вечная, но не переживает смену уровня: пол под ней уже другой.
blood.Reset();
atmos.Reset();
cam.SnapTo(game.squad.anchor);
prevShots = game.metrics.shotsFired;
prevKills = game.metrics.targetsKilled;
prevFriendly = game.metrics.friendlyHits;
prevMeleeHits = game.metrics.meleeHits;
}
void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, float frameDt)
{
const Squad& squad = game.squad;
view.RefreshRamp(); // подкрутили гамму в оверлее — таблица перепечётся
// --- камера ---------------------------------------------------------------
const Vec2 anchorView = Lerp(squad.anchorPrev, squad.anchor, alpha);
view.cam.Follow(anchorView, frameDt, g_tune.cameraSmooth);
// --- события боя -> эффекты, вспышки света и кровь ------------------------
view.fx.Ingest(game.events, squad.agents.data(), NUM_AGENTS);
view.lighting.Ingest(game.events, squad.agents.data(), NUM_AGENTS);
view.blood.Ingest(game.events, game.sprites, game.map);
view.fx.Update(frameDt);
view.atmos.Update(frameDt);
// --- напряжение ------------------------------------------------------------
DreadInput di;
di.shotsDelta = game.metrics.shotsFired - view.prevShots;
// Отдачу посчитал fx: он один знает, из чего именно стреляли.
di.shotShake = view.fx.shotShake;
view.fx.shotShake = 0.0f;
di.killsDelta = game.metrics.targetsKilled - view.prevKills;
di.friendlyDelta = game.metrics.friendlyHits - view.prevFriendly;
di.meleeHitsDelta = game.metrics.meleeHits - view.prevMeleeHits;
view.prevShots = game.metrics.shotsFired;
view.prevKills = game.metrics.targetsKilled;
view.prevFriendly = game.metrics.friendlyHits;
view.prevMeleeHits = game.metrics.meleeHits;
// «Отряд не слеп» — это и линия огня, и клинок в зоне удара: боец,
// достающий врага, беспомощным не выглядит и напряжение снимает так же.
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
if (a.IsMelee() ? a.melee.inReach : a.hasLane) ++di.agentsWithLane;
}
for (const Target& t : game.targets)
{
if (!t.alive) continue;
const float d = Distance(t.pos, squad.anchor);
di.nearestTargetDist = std::min(di.nearestTargetDist, d);
if (d < g_tune.dreadRadius) ++di.targetsNear;
// Страшнее всего то, что рядом и чего не видно.
if (view.lighting.LightAt(t.pos) < g_tune.fogEntityReveal)
di.nearestUnseenDist = std::min(di.nearestUnseenDist, d);
}
if (view.horrorEnabled) view.dread.Update(di, frameDt);
// --- свет ------------------------------------------------------------------
view.lighting.enabled = view.horrorEnabled;
view.lighting.Update(squad.agents.data(), NUM_AGENTS, game.map, frameDt);
// --- сцена -----------------------------------------------------------------
fb.Clear(COL_BG);
view.scene.Begin(view.cam);
// Отдача камеры добавляется к смещению сцены сразу после Begin — только там
// она согласована и с полом, и со списком сортировки, и с оверлеем.
if (view.horrorEnabled)
{
int sx = 0, sy = 0;
view.dread.CameraOffset(view.post.frame, sx, sy);
view.scene.ox += sx;
view.scene.oy += sy;
}
DrawFloor(fb, game.sprites, game.map, view.scene.ox, view.scene.oy, view.lighting,
&view.blood);
CollectWalls(view.scene, game.sprites, game.map, view.lighting);
// Мишени: показываются только там, где светло, либо если по ним уже работает
// кто-то из отряда (иначе стрельба в темноту выглядит абсурдом).
for (int i = 0; i < int(game.targets.size()); ++i)
{
const Target& t = game.targets[i];
if (!t.alive) continue;
const float lit = view.lighting.LightAt(t.pos);
bool engaged = false;
for (int a = 0; a < NUM_AGENTS; ++a)
if (squad.agents[a].currentTargetId == i) { engaged = true; break; }
// Тело — по реальному свету: в темноте мишени физически не видно.
if (lit >= g_tune.fogEntityReveal || engaged)
view.scene.AddShaded(&game.sprites.target, t.pos,
ShadeQuad::Flat(std::max(lit, engaged ? 0.30f : 0.0f)));
}
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
const Vec2 p = Lerp(a.prevPos, a.pos, alpha);
// Свои всегда читаемы: игрок обязан различать агентов даже в темноте.
const float lit = std::max(view.lighting.LightAt(p), g_tune.agentMinLight);
// Класс и ствол читаются силуэтом, номер — цветом.
const Sprite& spr = a.IsMelee() ? game.sprites.melee[i]
: (a.gunId == FirearmId::PISTOL) ? game.sprites.pistol[i]
: game.sprites.agent[i];
view.scene.AddShaded(&spr, p, ShadeQuad::Flat(lit));
}
view.scene.Flush(fb);
// --- глаза мишеней --------------------------------------------------------
// Тлеют при свете сильно ниже порога показа тела: в разведанной темноте
// игрок сначала видит огонёк, и только подойдя — того, кому он принадлежит.
for (const Target& t : game.targets)
{
if (!t.alive) continue;
if (view.lighting.LightAt(t.pos) < g_tune.eyeRevealLight) continue;
// Фаза пульсации своя у каждой мишени и учащается при низком HP.
const uint32_t seed = Hash2(uint32_t(int(t.pos.x * 4.0f)), uint32_t(int(t.pos.y * 4.0f)));
const float hpFrac = Clampf(t.hp / std::max(1.0f, g_tune.targetHP), 0.0f, 1.0f);
const float rate = Lerpf(3.4f, 1.1f, hpFrac);
const float ph = Hash01(seed) + float(view.post.frame) * (rate / 60.0f);
const float pulse = 0.55f + 0.45f * std::sin(ph * TWO_PI_F);
const Vec2 sp = iso::WorldToScreen(t.pos);
const int ex = int(std::lround(sp.x)) + view.scene.ox;
const int ey = int(std::lround(sp.y)) + view.scene.oy - int(g_tune.eyeLiftPx);
fb.AddGlowDisc(ex, ey, int(g_tune.eyeRadiusPx) + 1,
ScaleColor8(FXC(255, 40, 30), uint32_t(pulse * 256.0f)));
}
// --- эффекты боя: аддитивно поверх геометрии, до пост-прохода ------------
view.fx.Draw(fb, game.bullets, view.scene.ox, view.scene.oy, alpha);
DrawMeleeArcs(fb, squad.agents.data(), NUM_AGENTS, view.scene.ox, view.scene.oy, alpha);
// --- взвесь в воздухе ------------------------------------------------------
// Пепел висит МЕЖДУ камерой и сценой, поэтому рисуется последним из мира,
// но до пост-прохода: он такой же участник кадра, как трассеры.
if (view.horrorEnabled)
view.atmos.DrawAsh(fb, view.cam.pos, view.scene.ox, view.scene.oy, view.lighting);
// --- один слитый пост-проход ----------------------------------------------
view.post.enabled = view.horrorEnabled;
view.post.Apply(fb, view.dread, view.atmos.time);
}

52
src/render/view.h Normal file
View File

@ -0,0 +1,52 @@
#pragma once
// -----------------------------------------------------------------------------
// Всё render-only состояние живёт здесь и только здесь.
//
// Зачем отдельная структура: правило «эффекты не текут в симуляцию» должно
// проверяться компилятором, а не вниманием. RenderGame принимает Game по
// КОНСТАНТНОЙ ссылке — значит ни свет, ни трассеры, ни тряска камеры физически
// не могут изменить состояние симуляции. Headless-прогоны (--accept, --headless)
// View не создают вообще, поэтому цифры приёмки остаются цифрами симуляции.
// -----------------------------------------------------------------------------
#include "render/atmos.h"
#include "render/blood.h"
#include "render/framebuffer.h"
#include "render/fx.h"
#include "render/iso.h"
#include "render/lighting.h"
#include "render/post.h"
#include "render/scene.h"
struct Game;
struct View
{
iso::Camera cam;
Scene scene;
Lighting lighting;
FxSystem fx;
PostFx post;
Dread dread;
BloodField blood; // вечные декали, запечённые в спрайты пола
Atmosphere atmos; // пепел в воздухе
// Дельты считаются по накопительным счётчикам метрик: за один кадр рендера
// проходит до MAX_STEPS_PER_FRAME шагов, поэтому флаг «выстрелил» не годится.
int prevShots = 0;
int prevKills = 0;
int prevFriendly = 0;
int prevMeleeHits = 0;
bool horrorEnabled = true; // F5 — аварийный возврат к прежнему кадру
bool showSight = false; // F6 — показать диски обзора
// Гамма и усиление меняют САМУ таблицу рампы: без перепечатки ручки молчат.
float rampGamma = -1.0f;
float rampGain = -1.0f;
void Init();
void RefreshRamp();
void Reset(const Game& game);
};
void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, float frameDt);

173
src/sim/agent.cpp Normal file
View File

@ -0,0 +1,173 @@
#include "sim/agent.h"
Vec2 SeekForce(const Agent& a, Vec2 goal, float maxSpeed)
{
const Vec2 delta = goal - a.pos;
const float dist = Length(delta);
if (dist < 1e-4f) return {};
// У цели тормозим, иначе агент дрожит вокруг слота.
const float speed = maxSpeed * Clampf(dist / g_tune.arriveRadius, 0.0f, 1.0f);
return (delta / dist) * speed;
}
Vec2 SeparationForce(const Agent& a, const Agent* all, int count, float maxSpeed)
{
const float r = g_tune.separationRadius;
Vec2 push{};
for (int i = 0; i < count; ++i)
{
const Agent& other = all[i];
if (&other == &a) continue;
const Vec2 delta = a.pos - other.pos;
const float d = Length(delta);
if (d >= r) continue;
if (d < 1e-4f)
{
// Идеально совпали — расталкиваем детерминированно по индексу слота.
push += FromAngle(float(a.slotIndex) * (TWO_PI_F / NUM_AGENTS)) * maxSpeed;
continue;
}
push += (delta / d) * (maxSpeed * (1.0f - d / r));
}
return push;
}
Vec2 TargetPushForce(const Agent& a, const std::vector<Target>& targets, float maxSpeed)
{
Vec2 push{};
for (const Target& t : targets)
{
if (!t.alive) continue;
const float r = t.radius + a.bodyRadius + g_tune.targetPushGap;
const Vec2 delta = a.pos - t.pos;
const float d = Length(delta);
if (d >= r) continue;
// Ровно в центре мишени — толкаем по своему стволу, детерминированно.
const Vec2 n = (d > 1e-4f) ? (delta / d) : FromAngle(a.facing + PI_F);
push += n * (maxSpeed * (1.0f - d / r));
}
return push;
}
Vec2 WallAvoidForce(const Agent& a, const Tilemap& map, Vec2 seekDir, float maxSpeed)
{
const float reach = a.bodyRadius + g_tune.wallLookahead;
Vec2 push{};
const int x0 = int(std::floor(a.pos.x - reach));
const int x1 = int(std::floor(a.pos.x + reach));
const int y0 = int(std::floor(a.pos.y - reach));
const int y1 = int(std::floor(a.pos.y + reach));
for (int ty = y0; ty <= y1; ++ty)
{
for (int tx = x0; tx <= x1; ++tx)
{
if (!map.IsWall(tx, ty)) continue;
const Vec2 closest{Clampf(a.pos.x, float(tx), float(tx + 1)),
Clampf(a.pos.y, float(ty), float(ty + 1))};
const Vec2 delta = a.pos - closest;
const float d = Length(delta);
if (d >= reach) continue;
if (d < 1e-4f) continue; // внутри стены — этим займётся ResolveCircle
const Vec2 n = delta / d; // нормаль от стены к агенту
const float w = 1.0f - d / reach; // ближе к стене — сильнее
push += n * (maxSpeed * w);
// Стена стоит между агентом и целью: добавляем касательную,
// иначе seek и отталкивание уравновешиваются и агент залипает.
if (Dot(seekDir, n) < 0.0f)
{
Vec2 t = Perp(n);
if (Dot(t, seekDir) < 0.0f) t = Vec2{-t.x, -t.y};
push += t * (maxSpeed * w * g_tune.wallSlide);
}
}
}
return push;
}
void IntegrateAgent(Agent& a, Vec2 desiredVel, float maxSpeed, float dt, const Tilemap& map,
float accel)
{
desiredVel = ClampLength(desiredVel, maxSpeed);
// Скорость подтягивается к желаемой — резкие развороты не телепортируют агента.
const Vec2 dv = desiredVel - a.vel;
const float maxStep = ((accel > 0.0f) ? accel : g_tune.steerAccel) * dt;
a.vel += ClampLength(dv, maxStep);
a.vel = ClampLength(a.vel, maxSpeed);
a.pos += a.vel * dt;
// Скольжение вдоль стен: выталкивание гасит только нормальную компоненту.
const Vec2 before = a.pos;
map.ResolveCircle(a.pos, a.bodyRadius);
const Vec2 correction = a.pos - before;
if (LengthSq(correction) > 1e-8f)
{
const Vec2 n = Normalized(correction);
const float vn = Dot(a.vel, n);
if (vn < 0.0f) a.vel -= n * vn;
}
}
void AimAt(Agent& a, Vec2 point, float dt)
{
const Vec2 d = point - a.pos;
if (LengthSq(d) < 1e-6f) return;
a.facing = RotateToward(a.facing, AngleOf(d), g_tune.turnRateDeg * DEG2RAD_F * dt);
}
void AimAlongMotion(Agent& a, float dt)
{
if (LengthSq(a.vel) < 0.04f) return; // почти стоит — ствол не крутим
a.facing = RotateToward(a.facing, AngleOf(a.vel), g_tune.turnRateDeg * DEG2RAD_F * dt);
}
ecs::Entity SpawnWeapon(ecs::World& world, WeaponClass cls, FirearmId gunId)
{
const ecs::Entity e = world.Create();
WeaponItem item;
item.cls = cls;
item.gunId = gunId;
item.gun = FirearmDefOf(gunId);
item.blade = DefaultBladeDef();
world.Add<WeaponItem>(e, item);
return e;
}
void SyncLoadout(ecs::World& world, Agent& a)
{
a.bodyRadius = g_tune.bodyRadius;
const WeaponItem* item = world.Get<WeaponItem>(a.weaponEntity);
if (!item) return; // оружия нет — бойцу нечем ни стрелять, ни бить
a.cls = item->cls;
a.gunId = item->gunId;
// Ствол: статы обновляются целиком, состояние (патроны, откат, увод) — нет.
// Магазин мог смениться вместе с оружием, поэтому патроны подрезаем.
const bool sameGun = (a.gun.def.magazine == item->gun.magazine);
a.gun.def = item->gun;
if (!sameGun) a.gun.ammo = std::min(a.gun.ammo, a.gun.def.magazine);
a.melee.reach = item->blade.reach;
a.melee.arcDeg = item->blade.arcDeg;
a.melee.windup = item->blade.windup;
a.melee.strike = item->blade.strike;
a.melee.recover = item->blade.recover;
a.melee.interval = item->blade.interval;
a.melee.damage = item->blade.damage;
a.melee.knockback = item->blade.knockback;
}

119
src/sim/agent.h Normal file
View File

@ -0,0 +1,119 @@
#pragma once
// -----------------------------------------------------------------------------
// Агент: состояние + слои движения (спек 6.1, 6.7).
// Никакой изометрии, никакого рендера.
//
// Agent — компонент ECS. Крупный: правила боя читают состояние бойца целиком
// и в одном порядке, дробить его на Position+Velocity+Body значило бы разложить
// один связный алгоритм по пяти пулам без единой строчки выигрыша
// (см. docs/12-ecs.md). А вот ОРУЖИЕ вынесено в отдельную сущность: у него
// действительно своя жизнь, свои статы и своя подменяемость.
// -----------------------------------------------------------------------------
#include <vector>
#include "core/math.h"
#include "ecs/ecs.h"
#include "sim/loadout.h"
#include "sim/target.h"
#include "tuning.h"
#include "world/tilemap.h"
enum class AgentState : uint8_t
{
ADVANCING = 0, // есть ввод движения
HOLDING = 1, // ввода нет дольше holdDelay
};
struct Agent
{
Vec2 pos{};
Vec2 vel{};
float facing = 0.0f; // радианы, куда смотрит ствол
float bodyRadius = 0.28f;
int slotIndex = 0;
AgentState state = AgentState::HOLDING;
int currentTargetId = -1; // -1 = нет
Vec2 postPos{}; // выбранная огневая позиция
float commitTimer = 0.0f; // блокировка перерешивания
float solveTimer = 0.0f; // стаггер решателя
// Сущности ECS: сам боец и его оружие. Статы оружия копируются отсюда
// в gun/melee каждый шаг (SyncLoadout), поэтому ai/ про ECS не знает вообще.
ecs::Entity self = ecs::NO_ENTITY;
ecs::Entity weaponEntity = ecs::NO_ENTITY;
WeaponClass cls = WeaponClass::RANGED;
FirearmId gunId = FirearmId::RIFLE;
Firearm gun; // работает только у RANGED
MeleeWeapon melee; // работает только у MELEE
// --- служебное (рендер/отладка/метрики), на правила не влияет ---
Vec2 prevPos{}; // для интерполяции кадра
bool hasLane = false; // есть валидная линия огня прямо сейчас
bool priorityLane = false; // линия именно к приоритетной цели (6.8)
bool laneIntent = false; // цель есть, мешает ТОЛЬКО свой -> линия резервируется
bool targetBlockedByAlly = false; // цель есть, линию перекрыл свой
bool targetBlockedByWall = false; // цель есть, линию перекрыла стена
Vec2 laneEnd{}; // куда указывает текущая линия
bool fired = false; // выстрелил на этом шаге (для оверлея)
float Speed() const { return Length(vel); }
// Полураствор конуса выстрела прямо сейчас. Стоя он почти нулевой, на ходу
// растёт. Ровно этот конус проверяется на своих перед спуском.
float SpreadRad() const { return gun.SpreadRad(Speed()); }
// Точка вылета пули: muzzleOffset юнита от центра в сторону towards.
Vec2 MuzzleTowards(Vec2 towards) const
{
const Vec2 d = Normalized(towards - pos, FromAngle(facing));
return pos + d * gun.def.muzzleOffset;
}
Vec2 MuzzleFacing() const { return pos + FromAngle(facing) * gun.def.muzzleOffset; }
bool IsMelee() const { return cls == WeaponClass::MELEE; }
// Множитель скорости от фазы удара: на замахе ноги вкопаны, после удара
// боец медленный. Рывок в STRIKE считается отдельно, здесь его нет.
float SpeedScale() const
{
if (cls != WeaponClass::MELEE) return 1.0f;
switch (melee.state)
{
case MeleeState::WINDUP: return g_tune.meleeWindupScale;
case MeleeState::RECOVER: return g_tune.meleeRecoverScale;
default: return 1.0f;
}
}
};
// --- слои движения (6.7) -----------------------------------------------------
// 1. seek с торможением у цели
Vec2 SeekForce(const Agent& a, Vec2 goal, float maxSpeed);
// 2. расталкивание от союзников
Vec2 SeparationForce(const Agent& a, const Agent* all, int count, float maxSpeed);
// 3'. расталкивание от мишеней: чужое тело — не пустое место. Без этого боец
// ближнего боя стоит ВНУТРИ того, кого рубит, и удар не читается.
Vec2 TargetPushForce(const Agent& a, const std::vector<Target>& targets, float maxSpeed);
// 4. отталкивание от стен + скольжение вдоль них.
// seekDir нужен, чтобы обходить препятствие, а не упираться в него лбом:
// без тангенциальной составляющей агент залипает в «тени» стены.
Vec2 WallAvoidForce(const Agent& a, const Tilemap& map, Vec2 seekDir, float maxSpeed);
// 5. клампинг, интеграция, разрешение коллизий с тайлами.
// accel = -1 означает «взять steerAccel из tuning»; рывку клинка нужен свой.
void IntegrateAgent(Agent& a, Vec2 desiredVel, float maxSpeed, float dt, const Tilemap& map,
float accel = -1.0f);
// Доворот ствола к точке (или по вектору движения, если цели нет).
void AimAt(Agent& a, Vec2 point, float dt);
void AimAlongMotion(Agent& a, float dt);
// Система: скопировать статы с сущности-оружия и общие размеры из tuning.
// Рабочее состояние (патроны, фаза удара, откат, увод ствола) НЕ трогается.
void SyncLoadout(ecs::World& world, Agent& a);
// Создать сущность оружия нужного класса и вернуть её. Стволы и клинки живут
// в одном пуле WeaponItem: подменить одно другим — это одна запись компонента.
ecs::Entity SpawnWeapon(ecs::World& world, WeaponClass cls, FirearmId gunId);

135
src/sim/bullet.cpp Normal file
View File

@ -0,0 +1,135 @@
#include "sim/bullet.h"
#include "debug/metrics.h"
void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel,
int owner)
{
std::vector<Bullet>& bullets = world.All<Bullet>();
if (int(bullets.size()) >= MAX_BULLETS) return;
Bullet b;
b.self = world.Create();
b.pos = from;
b.prevPos = from;
b.origin = from;
b.vel = Normalized(dir) * def.bulletSpeed;
b.damage = def.damage;
b.knockback = def.knockback;
b.radius = def.bulletRadius;
// Живёт ровно столько, чтобы пройти проверенный отрезок (+ радиус мишени).
const float reach = std::min(travel + g_tune.targetRadius, def.range);
b.life = reach / std::max(0.001f, def.bulletSpeed);
b.owner = owner;
b.tracerLength = def.tracerLength;
b.tracerR = def.tracerR;
b.tracerG = def.tracerG;
b.tracerB = def.tracerB;
world.Add<Bullet>(b.self, b);
}
void UpdateBullets(ecs::World& world, const Tilemap& map,
Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, Metrics& metrics,
CombatEventLog* events)
{
std::vector<Bullet>& bullets = world.All<Bullet>();
// Убирать сущности прямо в цикле нельзя: удаление из пула переставляет
// хвост на освободившееся место. Поэтому сначала шаг, потом похороны.
ecs::Entity dead[MAX_BULLETS];
int deadCount = 0;
for (size_t bi = 0; bi < bullets.size(); ++bi)
{
Bullet& b = bullets[bi];
const float br = b.radius;
b.prevPos = b.pos;
b.pos += b.vel * dt;
b.life -= dt;
// Ищем самое раннее попадание вдоль пройденного за шаг отрезка.
float tBest = 2.0f;
int hitAgent = -1;
int hitTarget = -1;
float tWall = 0.0f;
if (map.RaycastBlocked(b.prevPos, b.pos, &tWall)) tBest = tWall;
if (g_tune.friendlyFire)
{
for (int i = 0; i < agentCount; ++i)
{
if (i == b.owner) continue; // своя пуля стрелка не бьёт
float t = 0.0f;
const Vec2 cp = ClosestPointOnSegment(agents[i].pos, b.prevPos, b.pos, &t);
if (Distance(cp, agents[i].pos) > agents[i].bodyRadius + br) continue;
if (t < tBest)
{
tBest = t;
hitAgent = i;
hitTarget = -1;
}
}
}
for (int i = 0; i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
float t = 0.0f;
const Vec2 cp = ClosestPointOnSegment(targets[i].pos, b.prevPos, b.pos, &t);
if (Distance(cp, targets[i].pos) > targets[i].radius + br) continue;
if (t < tBest)
{
tBest = t;
hitTarget = i;
hitAgent = -1;
}
}
// Точка события — там, где пуля реально столкнулась, а не b.pos:
// на кадре смерти b.pos уже за стеной/внутри цели.
const Vec2 impact = Lerp(b.prevPos, b.pos, Clampf(tBest, 0.0f, 1.0f));
const Vec2 dir = Normalized(b.vel);
if (hitAgent >= 0)
{
metrics.LogFriendlyHit(b.owner, hitAgent, agents[hitAgent].pos,
agents[hitAgent].state == AgentState::ADVANCING);
if (events) events->Push(CombatEventKind::HIT_ALLY, b.owner, impact, dir);
dead[deadCount++] = b.self;
continue;
}
if (hitTarget >= 0)
{
Target& t = targets[hitTarget];
t.hp -= b.damage;
KnockTarget(t, dir, b.knockback);
if (events) events->Push(CombatEventKind::HIT_TARGET, b.owner, impact, dir);
if (t.hp <= 0.0f)
{
t.alive = false;
t.respawnTimer = g_tune.targetRespawn;
++metrics.targetsKilled;
if (events) events->Push(CombatEventKind::HIT_KILL, b.owner, t.pos, dir);
}
dead[deadCount++] = b.self;
continue;
}
// Стена и «выработала дистанцию» — разные события для эффектов.
if (tBest <= 1.0f)
{
if (events) events->Push(CombatEventKind::HIT_WALL, b.owner, impact, dir);
dead[deadCount++] = b.self;
}
else if (b.life <= 0.0f)
{
if (events) events->Push(CombatEventKind::EXPIRED, b.owner, b.pos, dir);
dead[deadCount++] = b.self;
}
}
for (int i = 0; i < deadCount; ++i) world.Destroy(dead[i]);
}

54
src/sim/bullet.h Normal file
View File

@ -0,0 +1,54 @@
#pragma once
// -----------------------------------------------------------------------------
// Пули — настоящие снаряды (спек 6.9), не хитскан.
// Коллизия с тайлами и со ВСЕМИ капсулами, включая союзников: friendlyFire
// физически возможен, иначе прототип ничего не доказывает.
//
// Bullet — компонент ECS: у каждой пули своя сущность, которая умирает вместе
// с ней. Свободный список больше не нужен — его роль играет пул.
// -----------------------------------------------------------------------------
#include <vector>
#include "core/math.h"
#include "ecs/ecs.h"
#include "sim/agent.h"
#include "sim/events.h"
#include "sim/target.h"
#include "world/tilemap.h"
struct Metrics;
struct Bullet
{
ecs::Entity self = ecs::NO_ENTITY;
Vec2 pos{};
Vec2 prevPos{};
Vec2 vel{};
Vec2 origin{}; // откуда вылетела — только для эффектов, правила не читают
float damage = 0.0f;
float knockback = 0.0f;
float radius = 0.04f;
float life = 0.0f; // сек до самоуничтожения (дальность / скорость)
int owner = -1;
// Визуал трассера едет вместе со снарядом: он у каждого ствола свой,
// а к моменту отрисовки стрелок мог уже сменить оружие или умереть.
float tracerLength = 1.3f;
uint8_t tracerR = 255, tracerG = 240, tracerB = 205;
};
// travel — длина ПРОВЕРЕННОГО отрезка (дуло -> цель). Пуля не летит дальше:
// за целью пространство никто не проверял, и если цель успела умереть,
// пуля ушла бы в непроверенную зону и могла бы найти там своего.
//
// dir — уже с учётом разброса. Конус разброса проверен на своих ДО спуска
// (см. ai/firing_solver.cpp), поэтому отклонившаяся пуля своего не найдёт.
void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel,
int owner);
// events == nullptr в headless-прогонах: эффектов там нет, а правила журнал не читают.
void UpdateBullets(ecs::World& world, const Tilemap& map,
Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, Metrics& metrics,
CombatEventLog* events);

55
src/sim/events.h Normal file
View File

@ -0,0 +1,55 @@
#pragma once
// -----------------------------------------------------------------------------
// Единственный канал «симуляция -> визуальные эффекты».
//
// Почему кольцевой журнал, а не флаги вроде Agent::fired: за один кадр рендера
// проходит ДО MAX_STEPS_PER_FRAME шагов симуляции. Флаг, выставленный на шаге 1
// и затёртый на шаге 2, эффект не увидит. Журнал переживает все шаги кадра.
//
// Симуляция только ПИШЕТ сюда. Ни одно правило из спека 6 журнал не читает,
// поэтому на детерминизм и на критерии приёмки он не влияет. В headless-прогонах
// указатель на журнал равен nullptr.
// -----------------------------------------------------------------------------
#include <cstdint>
#include "core/math.h"
enum class CombatEventKind : uint8_t
{
SHOT = 0, // выстрел: pos = дуло, dir = направление
HIT_WALL, // пуля в стену
HIT_TARGET, // пуля в мишень
HIT_KILL, // мишень уничтожена
HIT_ALLY, // попадание по своему (в норме не должно случаться никогда)
EXPIRED, // пуля выработала проверенную дистанцию
MELEE_SWING, // взмах пошёл: pos = боец, dir = ось удара
MELEE_HIT, // лезвие достало мишень
MELEE_ABORT, // взмах оборван: в дугу влез свой
RELOAD_START, // магазин пуст, боец пошёл перезаряжаться
RELOAD_DONE, // магазин снаряжён
};
struct CombatEvent
{
CombatEventKind kind = CombatEventKind::SHOT;
int owner = -1; // индекс агента-стрелка
Vec2 pos{};
Vec2 dir{};
};
// Кольцо: если эффекты за кадр не успели забрать события, теряются самые старые.
constexpr int COMBAT_EVENT_CAP = 64;
struct CombatEventLog
{
CombatEvent buf[COMBAT_EVENT_CAP];
uint32_t head = 0; // всего записано за всё время; индекс = head % CAP
void Clear() { head = 0; }
void Push(CombatEventKind kind, int owner, Vec2 pos, Vec2 dir)
{
buf[head % COMBAT_EVENT_CAP] = CombatEvent{kind, owner, pos, dir};
++head;
}
};

177
src/sim/loadout.h Normal file
View File

@ -0,0 +1,177 @@
#pragma once
// -----------------------------------------------------------------------------
// Снаряжение бойца: РАБОЧЕЕ состояние оружия.
//
// Разделение простое и важное:
// * ЧТО такое ствол (дальность, урон, разброс, магазин, вид вспышки) —
// это FirearmDef на сущности-оружии, sim/weapon_defs.h;
// * В КАКОМ ОН СЕЙЧАС СОСТОЯНИИ (откат, патроны, перезарядка, увод ствола) —
// это Firearm здесь, и живёт оно на бойце.
// Подменили сущность оружия — поменялись все статы разом, состояние обнулилось,
// и ни одно правило боя об этом не узнало.
//
// Правила боя от класса НЕ зависят. И стрелок, и боец ближнего боя обязаны
// проверить своих перед ударом; меняется только геометрия проверки —
// конус «дуло -> цель» против сектора замаха (docs/11-melee.md).
// -----------------------------------------------------------------------------
#include <cstdint>
#include "core/hash.h"
#include "core/math.h"
#include "sim/weapon_defs.h"
#include "tuning.h"
// --- дальнобойное ------------------------------------------------------------
struct Firearm
{
FirearmDef def{}; // копия статов сущности-оружия, обновляется каждый шаг
// --- состояние ---
float cooldown = 0.0f;
int ammo = 0;
float reloadTimer = 0.0f; // > 0 — идёт перезарядка
float recoilDeg = 0.0f; // увод ствола, гаснет сам
uint32_t shotSeq = 0; // номер выстрела: сеет детерминированный разброс
bool Reloading() const { return reloadTimer > 0.0f; }
bool Empty() const { return ammo <= 0; }
// Доля магазина — для оверлея и HUD.
float AmmoFrac() const
{
return (def.magazine > 0) ? Clampf(float(ammo) / float(def.magazine), 0.0f, 1.0f) : 0.0f;
}
// Разброс в радианах: стоя — минимальный, на ходу растёт до spreadMoveDeg,
// сверху ложится накопленный увод ствола.
//
// Это ЕДИНСТВЕННОЕ место, где считается разброс. Его же читает проверка
// линии огня: конус, по которому реально уйдёт пуля, обязан быть тем самым
// конусом, который проверили на своих (см. ai/firing_solver.cpp).
float SpreadRad(float speed) const
{
const float m = Clampf(speed / std::max(0.01f, MaxAgentSpeed()), 0.0f, 1.0f);
const float deg = Lerpf(def.spreadStandDeg, def.spreadMoveDeg, m) + recoilDeg;
return std::max(0.0f, deg) * DEG2RAD_F * std::max(0.0f, g_tune.spreadScale);
}
// Отклонение конкретного выстрела внутри конуса. Не генератор: чистый хеш
// от (владелец, номер выстрела), поэтому прогон воспроизводим побитово.
float ShotOffset(int ownerIndex, float spreadRad) const
{
const uint32_t h = Hash2(uint32_t(ownerIndex) * 2654435761u, shotSeq);
// Треугольное распределение: центр конуса вероятнее краёв.
const float a = HashSigned(h);
const float b = HashSigned(HashU32(h ^ 0x9E3779B9u));
return 0.5f * (a + b) * spreadRad;
}
};
// --- ближний бой -------------------------------------------------------------
// Три фазы, как в любом ощутимом ударе: замах видно заранее, урон живёт
// в коротком окне, после удара боец открыт. Без разделения удар превращается
// в мгновенный хитскан и не читается на 480x270.
enum class MeleeState : uint8_t
{
IDLE = 0, // подходит или ждёт перезарядку
WINDUP, // замах: ноги вкопаны, ось удара уже зафиксирована
STRIKE, // активное окно: лезвие метёт дугу, есть рывок вперёд
RECOVER, // после удара — уязвим и медленный
};
struct MeleeWeapon
{
// --- параметры (копируются с сущности-оружия каждый шаг) ---
float reach = 1.15f; // от центра тела до конца замаха
float arcDeg = 100.0f; // ширина сектора
float windup = 0.18f;
float strike = 0.12f;
float recover = 0.26f;
float interval = 0.62f; // между началами замахов
float damage = 110.0f;
float knockback = 2.4f; // импульс мишени ДО её сопротивления отбросу
// --- состояние ---
MeleeState state = MeleeState::IDLE;
float phase = 0.0f; // остаток текущей фазы, сек
float cooldown = 0.0f;
float dir = 0.0f; // ось удара, зафиксирована на замахе
float sweepSign = 1.0f; // с какой стороны заходит лезвие (чередуется)
int targetId = -1;
bool inReach = false; // цель в зоне удара прямо сейчас
bool arcBlockedByAlly = false; // в секторе свой -> бить нельзя (аналог 6.3)
uint32_t hitMask = 0; // кого уже задели ЭТИМ взмахом
// --- связка ---
int comboStep = 0; // какой удар связки будет следующим
float comboTimer = 0.0f; // сколько ещё держится связка
// --- запечённые параметры ТЕКУЩЕГО взмаха ---
// Связка гнёт ширину, урон и тайминги, а поля выше переписываются из
// снаряжения КАЖДЫЙ шаг (SyncLoadoutFromTuning). Поэтому значения взмаха
// фиксируются на старте замаха и живут отдельно — иначе удар менял бы
// свойства прямо в полёте.
float curHalfArc = 0.0f;
float curDamage = 0.0f;
float curKnock = 0.0f;
float curWindup = 0.0f;
float curStrike = 0.0f;
float curRecover = 0.0f;
bool Committed() const
{
return state == MeleeState::WINDUP || state == MeleeState::STRIKE;
}
const MeleeComboStep& NextComboStep() const
{
return MELEE_COMBO[(comboStep >= 0 && comboStep < MELEE_COMBO_STEPS) ? comboStep : 0];
}
// Полуширина сектора. Во время взмаха — запечённая, иначе — та, что будет
// у СЛЕДУЮЩЕГО удара связки. Все потребители (проверка сектора, дуга,
// оверлей, резерв линии) обязаны видеть одно и то же число: иначе
// нарисованное и то, что бьёт, разъедутся.
float HalfArc() const
{
if (Committed() || state == MeleeState::RECOVER) return curHalfArc;
return arcDeg * 0.5f * DEG2RAD_F * NextComboStep().arcMul;
}
// Запечь параметры взмаха из базовых характеристик и шага связки.
void BakeSwing(const MeleeComboStep& cs)
{
curHalfArc = arcDeg * 0.5f * DEG2RAD_F * cs.arcMul;
curDamage = damage * cs.dmgMul;
curKnock = knockback * cs.knockMul;
curWindup = windup * cs.windupMul;
curStrike = strike * cs.strikeMul;
curRecover = recover * cs.recoverMul;
}
float PhaseLen() const
{
switch (state)
{
case MeleeState::WINDUP: return curWindup;
case MeleeState::STRIKE: return curStrike;
case MeleeState::RECOVER: return curRecover;
default: return 1.0f;
}
}
// 0 в начале фазы, 1 в конце.
float Progress() const
{
return Clampf(1.0f - phase / std::max(1e-4f, PhaseLen()), 0.0f, 1.0f);
}
// Где лезвие при заданном прогрессе взмаха: от одного края сектора к другому.
float BladeAngleAt(float progress) const
{
const float half = curHalfArc;
return WrapPi(dir + sweepSign * (-half + 2.0f * half * progress));
}
};

291
src/sim/squad.cpp Normal file
View File

@ -0,0 +1,291 @@
#include "sim/squad.h"
#include "ai/lane_registry.h"
namespace
{
// Локальные слоты строя: x = вперёд, y = вправо. Множатся на formationSpacing.
const float kWedge[NUM_AGENTS][2] = {
{ 1.0f, 0.0f},
{ 0.0f, -0.9f},
{ 0.0f, 0.9f},
{-1.0f, -1.8f},
{-1.0f, 1.8f},
};
const float kLine[NUM_AGENTS][2] = {
{0.0f, -2.0f},
{0.0f, -1.0f},
{0.0f, 0.0f},
{0.0f, 1.0f},
{0.0f, 2.0f},
};
} // namespace
const char* FormationName(Formation f)
{
switch (f)
{
case Formation::WEDGE: return "WEDGE";
case Formation::LINE: return "LINE";
case Formation::RING: return "RING";
default: return "?";
}
}
void Squad::Init(ecs::World& world, Vec2 at)
{
anchor = at;
anchorPrev = at;
anchorVel = {};
forward = {1.0f, 0.0f};
state = AgentState::HOLDING;
idleTimer = g_tune.holdDelay;
holdElapsed = 0.0f;
urgency = 0.0f;
sprinting = false;
formSpacing = 1.0f;
formStretch = 1.0f;
meleeCount = std::min(std::max(meleeCount, 0), NUM_AGENTS);
pistolCount = std::min(std::max(pistolCount, 0), NUM_AGENTS - meleeCount);
// Сначала создаём все сущности бойцов, и только потом берём указатель на
// плотный массив пула: до конца заполнения он может переехать.
ecs::Entity ids[NUM_AGENTS];
for (int i = 0; i < NUM_AGENTS; ++i)
{
ids[i] = world.Create();
world.Add<Agent>(ids[i], Agent{});
}
agents.p = world.All<Agent>().data();
agents.n = NUM_AGENTS;
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = agents[i];
a = Agent{};
a.self = ids[i];
a.slotIndex = i;
// Клинки занимают первые слоты: в клине это остриё и левое крыло,
// то есть ровно те, кто и так упирается в противника первым.
// Пистолеты — последние слоты: короткий ствол уезжает в хвост клина.
const bool isMelee = (i < meleeCount);
const bool isPistol = !isMelee && (i >= NUM_AGENTS - pistolCount);
a.cls = isMelee ? WeaponClass::MELEE : WeaponClass::RANGED;
a.gunId = isPistol ? FirearmId::PISTOL : FirearmId::RIFLE;
a.weaponEntity = SpawnWeapon(world, a.cls, a.gunId);
a.melee.sweepSign = (i & 1) ? -1.0f : 1.0f;
a.pos = SlotWorld(i);
a.prevPos = a.pos;
a.postPos = a.pos;
a.facing = 0.0f;
a.state = state;
a.currentTargetId = -1;
a.commitTimer = 0.0f;
// Фазы решателя разведены: за кадр решает максимум один агент (6.5).
a.solveTimer = g_tune.solveInterval * (float(i) / float(NUM_AGENTS));
SyncLoadout(world, a);
a.gun.ammo = a.gun.def.magazine; // выходим с полным магазином
}
}
Vec2 Squad::SlotLocal(int i) const
{
// Интервал и вытяжение — это ТЕКУЩЕЕ состояние строя, а не константа:
// на стоянке отряд собирается, в беге растягивается вдоль движения.
const float s = g_tune.formationSpacing * formSpacing;
const float fwd = s * formStretch;
switch (formation)
{
case Formation::LINE:
return {kLine[i][0] * fwd, kLine[i][1] * s};
case Formation::RING:
{
const float ang = float(i) * (TWO_PI_F / float(NUM_AGENTS));
const Vec2 d = FromAngle(ang) * 1.5f;
return {d.x * fwd, d.y * s};
}
case Formation::WEDGE:
default:
return {kWedge[i][0] * fwd, kWedge[i][1] * s};
}
}
Vec2 Squad::SlotWorld(int i) const
{
const Vec2 local = SlotLocal(i);
const Vec2 right = Perp(forward);
return anchor + forward * local.x + right * local.y;
}
Vec2 Squad::GoalFor(int i) const
{
// Боец ближнего боя ведёт себя одинаково в обоих состояниях: пока цель
// внутри его круга, он идёт к ней, а не в слот. Иначе клинок оказывался бы
// полезен только на стоянке — а он нужен как раз в движении.
// Но в спринте цель отменяется: бежим, а не деремся.
if (agents[i].IsMelee() && !sprinting) return agents[i].postPos;
if (agents[i].IsMelee()) return SlotWorld(i);
// В движении — слот строя, на месте — выбранная решателем огневая позиция.
return (state == AgentState::ADVANCING) ? SlotWorld(i) : agents[i].postPos;
}
Vec2 Squad::Centroid() const
{
Vec2 c{};
for (int i = 0; i < NUM_AGENTS; ++i) c += agents[i].pos;
return c / float(NUM_AGENTS);
}
void Squad::UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap& map, float dt)
{
anchorPrev = anchor;
const bool moving = LengthSq(moveWorldDir) > 1e-6f;
const AgentState prevState = state;
const Vec2 c = Centroid();
// --- натяжение поводка ----------------------------------------------------
// Поводок УПРУГИЙ, а не жёсткий. За мягкой границей курсор продолжает
// уходить, но идёт всё тяжелее, и до жёсткого предела не доходит никогда.
//
// Жёсткий поводок здесь не годился принципиально: он мгновенно упирается,
// дистанция намертво встаёт в одно значение, и по ней уже нельзя отличить
// «подправил направление» от «тяну отряд вперёд изо всех сил». Упругий
// поводок превращает дистанцию в непрерывную величину: чем сильнее тянешь,
// тем дальше уезжает курсор и тем быстрее бегут за ним.
const float soft = std::max(0.2f, g_tune.anchorLeash);
const float hard = std::max(soft + 0.2f, g_tune.anchorLeashMax);
const float start = soft * Clampf(g_tune.urgeStartFrac, 0.0f, 0.95f);
const float dist = Distance(anchor, c);
float pull = 0.0f;
if (moving) pull = Clampf((dist - start) / (hard - start), 0.0f, 1.0f);
if (moving && sprint) pull = 1.0f; // Shift — приказ бежать, без уговоров
const float rate = (pull > urgency) ? g_tune.urgeAttack : g_tune.urgeRelease;
urgency += (pull - urgency) * (1.0f - std::exp(-std::max(0.01f, rate) * dt));
urgency = Clampf(urgency, 0.0f, 1.0f);
// Бег без стрельбы — либо по прямому приказу, либо когда курсор реально
// уехал: отряд догоняет и ему не до целей.
sprinting = (moving && sprint) || (urgency >= g_tune.sprintAt);
// --- сам курсор -----------------------------------------------------------
// У него есть разгон и торможение: мгновенная смена скорости — это и есть
// «топорно». Отпустил клавиши — курсор доезжает, а не втыкается в грунт.
const float over = Clampf((dist - soft) / (hard - soft), 0.0f, 1.0f);
const float dragK = std::max(0.08f, 1.0f - Clampf(g_tune.leashDrag, 0.0f, 1.0f) * over);
const float speed = (sprint ? g_tune.anchorSpeedFast : g_tune.anchorSpeed) * dragK;
const Vec2 want = moving ? Normalized(moveWorldDir, forward) * speed : Vec2{};
const Vec2 dv = want - anchorVel;
anchorVel += ClampLength(dv, std::max(0.1f, g_tune.anchorAccel) * dt);
anchor += anchorVel * dt;
if (moving) forward = Normalized(moveWorldDir, forward);
// Жёсткий предел. Сопротивление обычно не даёт до него дойти, но отряд может
// и застрять в стене — тогда курсор без предела уехал бы за полкарты.
const Vec2 d = anchor - c;
const float len = Length(d);
if (len > hard && len > 1e-5f)
{
const Vec2 n = d / len;
anchor = c + n * hard;
const float vn = Dot(anchorVel, n);
if (vn > 0.0f) anchorVel -= n * vn;
}
map.ResolveCircle(anchor, g_tune.anchorRadius);
// --- состояние отряда ------------------------------------------------------
if (moving)
{
idleTimer = 0.0f;
state = AgentState::ADVANCING;
holdElapsed = 0.0f;
}
else
{
idleTimer += dt;
if (idleTimer >= g_tune.holdDelay) state = AgentState::HOLDING;
if (state == AgentState::HOLDING) holdElapsed += dt;
}
// --- дыхание строя ---------------------------------------------------------
const float wantSpacing = (state == AgentState::HOLDING)
? g_tune.holdSpacing
: Lerpf(1.0f, g_tune.sprintSpacing, urgency);
const float wantStretch = Lerpf(1.0f, g_tune.sprintStretch, urgency);
const float k = 1.0f - std::exp(-std::max(0.01f, g_tune.formMorphRate) * dt);
formSpacing += (wantSpacing - formSpacing) * k;
formStretch += (wantStretch - formStretch) * k;
if (state != prevState)
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = agents[i];
a.commitTimer = 0.0f;
// Встали — остаёмся на месте, пока решатель не найдёт лучше (6.5).
if (state == AgentState::HOLDING) a.postPos = a.pos;
}
if (state == AgentState::HOLDING) holdElapsed = 0.0f;
}
for (int i = 0; i < NUM_AGENTS; ++i) agents[i].state = state;
}
void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
const std::vector<Target>& targets, float dt)
{
// Скорость — не тумблер «шаг/рывок», а непрерывная величина от натяжения.
const float baseSpeed = MoveSpeed();
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = agents[i];
a.prevPos = a.pos;
const Vec2 goal = GoalFor(i);
const bool lunging = a.IsMelee() && a.melee.state == MeleeState::STRIKE;
// --- рывок клинка --------------------------------------------------
// В активном окне удара ось уже зафиксирована, и боец едет по ней.
// Seek/separation/расступание тут молчат: удар — это обязательство,
// а не предложение. Стены остаются: сквозь них не рвётся никто.
if (lunging)
{
const float speed = g_tune.meleeLungeSpeed;
const Vec2 dir = FromAngle(a.melee.dir);
Vec2 desired = dir * speed;
desired += WallAvoidForce(a, map, dir, speed) * g_tune.wWallAvoid;
IntegrateAgent(a, desired, speed, dt, map, g_tune.meleeLungeAccel);
continue;
}
// --- обычные слои, порядок 6.7 --------------------------------------
const float maxSpeed = baseSpeed * a.SpeedScale();
Vec2 desired = SeekForce(a, goal, maxSpeed) * g_tune.wSeek;
desired += SeparationForce(a, agents.data(), NUM_AGENTS, maxSpeed) * g_tune.wSeparation;
// Замах — это обязательство. Выдёргивать бойца из чужой линии огня
// посреди него бессмысленно: стрелок всё равно не выстрелит (правило
// 6.3 запрещает), а удар сорвётся. Расступаться боец будет до и после.
if (lanes && !(a.IsMelee() && a.melee.Committed()))
desired += lanes->AvoidanceForce(i, a.pos, a.bodyRadius, maxSpeed) * g_tune.wLaneAvoid;
desired += TargetPushForce(a, targets, maxSpeed) * g_tune.wTargetPush;
desired += WallAvoidForce(a, map, Normalized(goal - a.pos), maxSpeed) * g_tune.wWallAvoid;
IntegrateAgent(a, desired, maxSpeed, dt, map);
}
}

93
src/sim/squad.h Normal file
View File

@ -0,0 +1,93 @@
#pragma once
// -----------------------------------------------------------------------------
// Отряд: якорь, строй, состояния (спек 6.2), сборка слоёв движения агентов.
//
// Якорь («внутренний курсор») — не телепорт и не переключатель. У него своя
// скорость, свой разгон и упругий поводок до центра масс отряда. Чем сильнее
// игрок тянет курсор от отряда, тем выше НАТЯЖЕНИЕ (urgency): отряд ускоряется,
// строй растягивается вдоль движения, а на самом верху шкалы бойцы просто
// бегут и не стреляют вовсе. Подробности — docs/05-squad-ai.md.
// -----------------------------------------------------------------------------
#include <vector>
#include "ecs/ecs.h"
#include "sim/agent.h"
#include "sim/target.h"
#include "world/tilemap.h"
struct LaneRegistry; // ai/lane_registry.h — расступание из чужих линий (6.6)
enum class Formation : uint8_t
{
WEDGE = 0, // клин
LINE = 1, // линия
RING = 2, // кольцо
COUNT = 3,
};
const char* FormationName(Formation f);
// Окно в пул компонентов Agent: снаружи это по-прежнему «массив агентов».
// Пул плотный, поэтому data() — обычный сплошной Agent*, и ai/ про ECS не знает.
struct AgentSpan
{
Agent* p = nullptr;
int n = 0;
Agent& operator[](int i) { return p[i]; }
const Agent& operator[](int i) const { return p[i]; }
Agent* data() { return p; }
const Agent* data() const { return p; }
int size() const { return n; }
};
struct Squad
{
AgentSpan agents;
Vec2 anchor{};
Vec2 anchorPrev{};
Vec2 anchorVel{}; // у курсора есть инерция, а не только позиция
Vec2 forward{1.0f, 0.0f}; // ориентация строя = направление движения
AgentState state = AgentState::HOLDING;
float idleTimer = 0.0f; // сколько нет ввода
float holdElapsed = 0.0f; // сколько уже в HOLDING (для SETTLE TIME)
Formation formation = Formation::WEDGE;
// --- натяжение поводка ---------------------------------------------------
// 0 — курсор рядом, отряд идёт шагом и работает по целям.
// 1 — курсор упёрся в поводок, отряд бежит и огня не ведёт.
float urgency = 0.0f;
bool sprinting = false; // urgency выше порога: бег без атаки
// Текущее состояние строя. Меняется плавно: строй ДЫШИТ, а не щёлкает
// между двумя таблицами координат.
float formSpacing = 1.0f; // общий множитель интервала
float formStretch = 1.0f; // вытяжение вдоль направления движения
// Сколько первых слотов держат клинок. Ставится ДО Init.
int meleeCount = DEFAULT_MELEE_COUNT;
// Сколько ПОСЛЕДНИХ слотов носят пистолет вместо карабина. Ставится ДО Init.
int pistolCount = DEFAULT_PISTOL_COUNT;
void Init(ecs::World& world, Vec2 at);
Vec2 SlotLocal(int i) const; // локальные координаты слота (x = вперёд)
Vec2 SlotWorld(int i) const; // слот в мире
Vec2 GoalFor(int i) const; // куда агент реально идёт
Vec2 Centroid() const; // центр масс отряда — к нему привязан поводок
int RangedCount() const { return NUM_AGENTS - meleeCount; }
// Текущая скорость пешего хода отряда с учётом натяжения.
float MoveSpeed() const { return Lerpf(g_tune.moveSpeed, g_tune.moveSpeedFast, urgency); }
// Движение якоря (в МИРОВЫХ осях — перевод из экранных делает вызывающий),
// натяжение поводка, дыхание строя и определение ADVANCING/HOLDING.
void UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap& map, float dt);
// Слои движения агентов: seek -> separation -> laneAvoidance -> targetPush
// -> wallAvoidance. Рывок клинка подменяет seek целиком.
void UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
const std::vector<Target>& targets, float dt);
};

96
src/sim/target.cpp Normal file
View File

@ -0,0 +1,96 @@
#include "sim/target.h"
namespace
{
// Кучки: 5 на арене (рядом со стартом), 3 в северной комнате,
// 3 в западной, 1 одиночная в восточной. Всего 12.
//
// Северная кучка стоит НАПРОТИВ прохода x=30..31: из коридора до неё есть
// чистая линия. Это тот самый тест-кейс из спека — в узком проходе правило
// «не стрелять сквозь своих» упирается в геометрию, и отряд обязан вытянуться
// в колонну: стреляет передний, задние молчат.
//
// Кучка обязана быть В ПРЕДЕЛАХ ДАЛЬНОСТИ от середины прохода (y≈14), иначе
// стрелять из коридора не по чему и тест-кейс проверяет пустоту. При weaponRange
// 7.2 ближняя мишень стоит в ~6.5 юнита: запас есть, но небольшой — если менять
// дальность, эти координаты надо пересчитывать вместе с ней.
const float kTargetSpots[NUM_TARGETS][2] = {
{30.5f, 20.5f}, {31.5f, 22.5f}, {29.5f, 23.5f}, {32.5f, 21.5f}, {30.5f, 24.5f},
{29.5f, 8.5f}, {31.5f, 7.5f}, {27.5f, 10.5f},
{ 7.5f, 40.5f}, { 9.5f, 42.5f}, { 6.5f, 43.5f},
{43.5f, 35.5f},
};
// Сопротивление отбросу по слотам. Не случайное: в каждой кучке есть кто-то
// тяжёлый, кого клинком не сдвинуть, и кто-то лёгкий, кто улетает от первого
// же удара. Иначе «отброс» и «сопротивление» — два числа, которых не видно.
const float kTargetKnockResist[NUM_TARGETS] = {
0.00f, 0.65f, 0.15f, 0.40f, 0.00f,
0.55f, 0.10f, 0.30f,
0.20f, 0.70f, 0.05f,
0.85f,
};
} // namespace
void SpawnTargets(ecs::World& world)
{
for (int i = 0; i < NUM_TARGETS; ++i)
{
Target t;
t.self = world.Create();
t.pos = {kTargetSpots[i][0], kTargetSpots[i][1]};
t.home = t.pos;
t.vel = {};
t.hp = g_tune.targetHP;
t.maxHp = g_tune.targetHP;
t.radius = g_tune.targetRadius;
t.respawnTimer = 0.0f;
t.alive = true;
t.knockResist = kTargetKnockResist[i];
world.Add<Target>(t.self, t);
}
}
void KnockTarget(Target& t, Vec2 dir, float speed)
{
t.vel += dir * (speed * t.KnockScale());
}
void UpdateTargets(std::vector<Target>& targets, float dt)
{
// Отдача гасится и мишень возвращается на своё место: расстановка уровня
// захардкожена, и «уползание» мишеней ломало бы все замеры разом.
const float damp = std::exp(-g_tune.targetKnockDamp * dt);
const float pull = 1.0f - std::exp(-g_tune.targetReturnRate * dt);
for (Target& t : targets)
{
t.radius = g_tune.targetRadius;
t.maxHp = g_tune.targetHP;
t.pos += t.vel * dt;
t.vel *= damp;
t.pos += (t.home - t.pos) * pull;
if (t.alive) continue;
t.respawnTimer -= dt;
if (t.respawnTimer <= 0.0f)
{
t.alive = true;
t.hp = g_tune.targetHP;
t.pos = t.home;
t.vel = {};
}
}
}
int CountAliveTargets(const std::vector<Target>& targets)
{
int n = 0;
for (const Target& t : targets)
if (t.alive) ++n;
return n;
}

52
src/sim/target.h Normal file
View File

@ -0,0 +1,52 @@
#pragma once
// -----------------------------------------------------------------------------
// Мишени: статичные, не атакуют, респавнятся на том же месте.
// 12 штук кучками (5 / 3 / 3 / 1) — расстановка захардкожена.
//
// Target — компонент ECS: сущности мишеней создаются в SpawnTargets, а вектор,
// по которому ходят правила, — это плотный массив пула (см. ecs/ecs.h).
// -----------------------------------------------------------------------------
#include <vector>
#include "core/math.h"
#include "ecs/ecs.h"
#include "tuning.h"
struct Target
{
ecs::Entity self = ecs::NO_ENTITY;
Vec2 pos{};
// Своё место. Удар отбрасывает мишень, после чего она возвращается сюда:
// без отдачи попадание в упор ничем не отличается от промаха.
Vec2 home{};
Vec2 vel{};
float hp = 0.0f;
float maxHp = 0.0f;
float respawnTimer = 0.0f;
float radius = 0.34f;
bool alive = true;
// Сопротивление отбросу, 0..1. 0 — мишень летит от удара как есть,
// 1 — не сдвигается вообще. Тяжёлая цель гасит импульс клинка, и это
// единственное, чем она отличается от лёгкой при равном HP.
float knockResist = 0.0f;
// Во сколько раз ослабляется импульс с учётом общего множителя из tuning.
float KnockScale() const
{
const float r = Clampf(knockResist * g_tune.knockResistScale, 0.0f, 0.98f);
return 1.0f - r;
}
};
// Импульс от удара (dir нормализован вызывающим). speed — импульс ДО того,
// как мишень ему сопротивляется: сопротивление применяется здесь, в одном месте,
// иначе половина источников урона про него забудет.
void KnockTarget(Target& t, Vec2 dir, float speed);
void SpawnTargets(ecs::World& world);
void UpdateTargets(std::vector<Target>& targets, float dt);
// Число живых мишеней (знаменатель в оценке покрытия, спек 6.5).
int CountAliveTargets(const std::vector<Target>& targets);

207
src/sim/weapon_defs.h Normal file
View File

@ -0,0 +1,207 @@
#pragma once
// -----------------------------------------------------------------------------
// Каталог оружия: ЧТО такое ствол или клинок, в отрыве от того, кто его держит.
//
// Оружие — отдельная сущность ECS с компонентом WeaponItem. Боец хранит только
// ссылку на неё (Agent::weaponEntity) и рабочее состояние выстрела (патроны,
// откат, увод ствола). Поэтому «дать бойцу пистолет» — это подменить компонент
// на его сущности-оружии, а не править ни агента, ни правила боя.
//
// Где живут числа. Общее правило прототипа — все числа в tuning.h. Здесь оно
// соблюдено с одной оговоркой, записанной в docs/08-decisions.md: КАРАБИН
// (оружие по умолчанию, то самое, на котором меряются критерии приёмки) читает
// свои параметры из g_tune, поэтому все прежние ручки оверлея и --set работают
// как работали. Остальные стволы — записи каталога: у второго ствола не может
// быть «того же самого» weaponRange, иначе он не второй ствол.
//
// Визуальные параметры атаки лежат ЗДЕСЬ ЖЕ, но в нейтральном виде: длины,
// радиусы в пикселях и байты RGB. Ни одного типа рендера в sim/ не появилось —
// цвет собирает render/fx.cpp. Так у пистолета своя вспышка и свой трассер,
// и при этом правило «sim не знает про рендер» не нарушено.
// -----------------------------------------------------------------------------
#include <cstdint>
#include "core/math.h"
#include "tuning.h"
enum class WeaponClass : uint8_t
{
RANGED = 0, // ствол: линия огня, снаряд, правило 6.3
MELEE = 1, // клинок: сектор замаха, то же правило в угловой форме
};
inline const char* WeaponClassName(WeaponClass c)
{
return (c == WeaponClass::MELEE) ? "MELEE" : "RANGED";
}
enum class FirearmId : uint8_t
{
RIFLE = 0, // штатный карабин отряда: числа из tuning.h
PISTOL = 1, // короткоствол: ближе, быстрее, мельче магазин
COUNT = 2,
};
// --- ствол -------------------------------------------------------------------
struct FirearmDef
{
const char* name = "rifle";
// баллистика и урон
float range = 7.2f;
float fireInterval = 0.35f;
float damage = 12.0f;
float laneHalfWidth = 0.10f;
float muzzleOffset = 0.30f;
float bulletSpeed = 22.0f;
float bulletRadius = 0.04f;
float knockback = 0.35f; // импульс мишени от попадания
// разброс: стоя, на полном ходу, и увод ствола за выстрел
float spreadStandDeg = 0.9f;
float spreadMoveDeg = 4.2f;
float spreadRecoilDeg = 0.55f;
float spreadRecoilMax = 3.0f;
float recoilDecay = 3.2f; // 1/сек
// перезарядка
int magazine = 24;
float reloadTime = 1.9f;
// визуал атаки (нейтральные числа: пиксели, секунды, байты RGB)
float tracerLength = 1.30f;
float muzzleRadiusPx = 5.0f;
float muzzleTime = 0.07f;
float muzzleFlamePx = 0.35f; // длина языка пламени, юнитов мира
float flashPower = 0.55f; // как источник света
float flashRadius = 4.0f;
float shakePerShot = 0.55f;
uint8_t tracerR = 255, tracerG = 240, tracerB = 205;
uint8_t muzzleR = 255, muzzleG = 225, muzzleB = 150;
};
// --- клинок ------------------------------------------------------------------
struct BladeDef
{
const char* name = "blade";
float reach = 1.40f;
float arcDeg = 100.0f;
float windup = 0.17f;
float strike = 0.13f;
float recover = 0.24f;
float interval = 0.60f;
float damage = 130.0f;
float knockback = 2.4f; // импульс мишени, юнитов/сек
float lungeSpeed = 3.4f; // рывок в момент удара
};
// --- компонент сущности-оружия ------------------------------------------------
struct WeaponItem
{
WeaponClass cls = WeaponClass::RANGED;
FirearmId gunId = FirearmId::RIFLE;
FirearmDef gun{};
BladeDef blade{};
};
// --- каталог ------------------------------------------------------------------
// Карабин: параметры берутся из tuning, чтобы прежние ручки и --set остались
// рабочими (см. шапку файла).
inline FirearmDef RifleDef()
{
FirearmDef d;
d.name = "rifle";
d.range = g_tune.weaponRange;
d.fireInterval = g_tune.fireInterval;
d.damage = g_tune.damage;
d.laneHalfWidth = g_tune.laneHalfWidth;
d.muzzleOffset = g_tune.muzzleOffset;
d.bulletSpeed = g_tune.bulletSpeed;
d.bulletRadius = g_tune.bulletRadius;
d.knockback = g_tune.bulletKnock;
d.spreadStandDeg = g_tune.spreadStandDeg;
d.spreadMoveDeg = g_tune.spreadMoveDeg;
d.spreadRecoilDeg = g_tune.spreadRecoilDeg;
d.spreadRecoilMax = g_tune.spreadRecoilMax;
d.recoilDecay = g_tune.spreadRecoilDecay;
d.magazine = int(g_tune.magazineRifle);
d.reloadTime = g_tune.reloadRifle;
d.tracerLength = g_tune.tracerLength;
d.muzzleRadiusPx = g_tune.muzzleRadiusPx;
d.muzzleTime = g_tune.muzzleTime;
d.flashPower = g_tune.flashPower;
d.flashRadius = g_tune.flashRadius;
d.shakePerShot = g_tune.shakePerShot;
return d;
}
// Пистолет: бьёт ближе и слабее, но чаще; на ходу «гуляет» заметно сильнее
// карабина, зато перезаряжается вдвое быстрее. Вспышка мельче и холоднее,
// трассер короче — ствол должен читаться по картинке, а не только по цифрам.
inline FirearmDef PistolDef()
{
FirearmDef d;
d.name = "pistol";
d.range = 4.6f;
d.fireInterval = 0.22f;
d.damage = 8.0f;
d.laneHalfWidth = 0.10f;
d.muzzleOffset = 0.24f;
d.bulletSpeed = 17.0f;
d.bulletRadius = 0.04f;
d.knockback = 0.22f;
d.spreadStandDeg = 1.6f;
d.spreadMoveDeg = 6.5f;
d.spreadRecoilDeg = 0.9f;
d.spreadRecoilMax = 4.5f;
d.recoilDecay = 4.5f;
d.magazine = 7;
d.reloadTime = 1.15f;
d.tracerLength = 0.85f;
d.muzzleRadiusPx = 3.5f;
d.muzzleTime = 0.055f;
d.muzzleFlamePx = 0.26f;
d.flashPower = 0.42f;
d.flashRadius = 3.2f;
d.shakePerShot = 0.34f;
d.tracerR = 255; d.tracerG = 214; d.tracerB = 170;
d.muzzleR = 255; d.muzzleG = 205; d.muzzleB = 130;
return d;
}
inline FirearmDef FirearmDefOf(FirearmId id)
{
return (id == FirearmId::PISTOL) ? PistolDef() : RifleDef();
}
inline const char* FirearmName(FirearmId id)
{
return (id == FirearmId::PISTOL) ? "PISTOL" : "RIFLE";
}
// Клинок пока один, и его числа целиком в tuning.
inline BladeDef DefaultBladeDef()
{
BladeDef d;
d.reach = g_tune.meleeReach;
d.arcDeg = g_tune.meleeArcDeg;
d.windup = g_tune.meleeWindup;
d.strike = g_tune.meleeStrike;
d.recover = g_tune.meleeRecover;
d.interval = g_tune.meleeInterval;
d.damage = g_tune.meleeDamage;
d.knockback = g_tune.meleeKnockback;
d.lungeSpeed = g_tune.meleeLungeSpeed;
return d;
}

459
src/tuning.h Normal file
View File

@ -0,0 +1,459 @@
#pragma once
#include <algorithm>
// -----------------------------------------------------------------------------
// Единственное место, где живут числа прототипа.
// В коде не должно быть магических констант — всё берётся отсюда.
//
// * блок COMPILE-TIME — размеры буферов/карты, менять только пересборкой;
// * struct Tuning — всё, что подкручивается в рантайме ([ ] выбор, - = шаг).
// -----------------------------------------------------------------------------
// --- compile-time константы --------------------------------------------------
constexpr int INTERNAL_W = 480; // внутреннее разрешение фреймбуфера
constexpr int INTERNAL_H = 270;
constexpr int WINDOW_SCALE = 3; // целочисленный апскейл
constexpr int WINDOW_W = INTERNAL_W * WINDOW_SCALE;
constexpr int WINDOW_H = INTERNAL_H * WINDOW_SCALE;
constexpr int TILE_W = 32; // ромб пола
constexpr int TILE_H = 16;
constexpr int TILE_HALF_W = TILE_W / 2;
constexpr int TILE_HALF_H = TILE_H / 2;
constexpr int WALL_H = 32; // спрайт стены 32x32 (верхний ромб + грани)
constexpr float FIXED_DT = 1.0f / 60.0f; // шаг симуляции
constexpr int MAX_STEPS_PER_FRAME = 5; // защита от спирали смерти
constexpr int MAP_W = 48;
constexpr int MAP_H = 48;
// Точка старта отряда (центральная арена).
constexpr float SQUAD_START_X = 26.5f; // ближе к кучке: дальность стала 7.2
constexpr float SQUAD_START_Y = 26.5f;
constexpr int NUM_AGENTS = 5;
constexpr int NUM_TARGETS = 12;
constexpr int MAX_BULLETS = 512;
// Сколько слотов строя занимают бойцы ближнего боя (слоты 0..N-1).
// В клине это остриё и левое крыло — те, кто и так идёт первым.
constexpr int DEFAULT_MELEE_COUNT = 2;
// Сколько ПОСЛЕДНИХ слотов носят пистолет вместо карабина.
// Короткий ствол уезжает в хвост клина: ему там и место.
constexpr int DEFAULT_PISTOL_COUNT = 1;
// Сколько тайлов пола могут нести запечённую кровь. Каждый — своя копия
// спрайта 32x16 (2 КБ), то есть потолок ~2.4 МБ. Дальше новые пятна ложатся
// только на уже запачканные тайлы: декали вечные, но не бесконечные.
constexpr int BLOOD_TILE_BUDGET = 1200;
// Решатель огневых позиций: 3 кольца x 12 направлений + текущая позиция.
constexpr int SOLVER_RINGS = 3;
constexpr int SOLVER_DIRS = 12;
constexpr int SOLVER_CANDIDATES = SOLVER_RINGS * SOLVER_DIRS + 1;
// --- runtime-тюнинг ----------------------------------------------------------
struct Tuning
{
// движение агента
float moveSpeed = 1.75f; // юнитов/сек: хоррор, а не шутер-беготня
float moveSpeedFast = 3.6f; // догоняют курсор быстро — иначе отряд отстаёт
float steerAccel = 18.0f; // тяжелее; НИЖЕ 16 нельзя — ломается критерий 11.4
float bodyRadius = 0.28f;
float separationRadius = 0.7f;
float wSeek = 1.0f;
float wSeparation = 1.0f;
float wLaneAvoid = 2.4f; // строго выше wSeparation (спек 6.7)
float wWallAvoid = 1.2f;
float wallLookahead = 0.8f;
float wallSlide = 1.4f; // касательная составляющая обхода стен
float arriveRadius = 0.18f;
float turnRateDeg = 300.0f; // ствол тяжёлый
float wTargetPush = 1.0f; // мишень — тело, а не пустое место
float targetPushGap = 0.10f; // зазор поверх суммы радиусов
// оружие (это ПАРАМЕТРЫ КАРАБИНА — оружия по умолчанию, на котором меряются
// критерии приёмки. Остальные стволы — записи каталога sim/weapon_defs.h)
float weaponRange = 7.2f; // -20% от исходных 9.0
float fireInterval = 0.35f;
float laneHalfWidth = 0.10f;
float damage = 12.0f;
float safetyMargin = 0.15f;
float muzzleOffset = 0.3f;
float bulletSpeed = 22.0f;
float bulletRadius = 0.04f;
float bulletKnock = 0.35f; // импульс мишени от попадания пули
float aimToleranceDeg = 5.0f; // ствол должен быть доведён до цели
// --- разброс -------------------------------------------------------------
// Стоя ствол почти не гуляет, на полном ходу гуляет сильно, очередь его
// раскрывает. Тот же конус проверяется на своих перед спуском: правило 6.3
// не ослаблено, оно просто считает по конусу вместо отрезка.
float spreadStandDeg = 0.9f;
float spreadMoveDeg = 4.2f;
float spreadRecoilDeg = 0.55f; // прибавка за выстрел
float spreadRecoilMax = 3.0f;
float spreadRecoilDecay = 3.2f; // градусов/сек оседания
float spreadScale = 1.0f; // общий множитель, ручка для замеров
// --- перезарядка ---------------------------------------------------------
float magazineRifle = 24.0f; // патронов в магазине карабина
float reloadRifle = 1.9f; // сек
// Запас на манёвр союзника за время полёта пули (доля от moveSpeed).
// 0 = верить экстраполяции скорости буквально.
float shotPredictSlack = 0.3f;
// --- ближний бой ---------------------------------------------------------
// Три фазы: замах видно, урон живёт в коротком окне, после удара боец открыт.
float meleeReach = 1.40f; // от центра тела до конца замаха
float meleeArcDeg = 100.0f; // ширина сектора удара
float meleeWindup = 0.17f; // замах — телеграф
float meleeStrike = 0.13f; // активное окно урона
float meleeRecover = 0.24f; // после удара
float meleeInterval = 0.60f; // между началами замахов
float meleeDamage = 130.0f; // 4 удара на мишень против 42 пуль
float meleeBladeDeg = 34.0f; // ширина полосы самого лезвия
float meleeAimToleranceDeg = 30.0f; // допуск разворота на старте замаха
float meleeAllyMargin = 0.14f; // запас к телу союзника в секторе
// На сколько взмахов вперёд смотреть на союзника при старте замаха
// (в долях windup+strike). 0 = верить только текущему положению.
float meleeStartPredict = 1.0f;
// Доля бокового размаха сектора, которую клинок резервирует в реестре линий.
// 1.0 = вся дуга; тогда своим нечем дышать. 0 = резерв шириной с отрезок.
float meleeReserveFrac = 0.55f;
float meleeGap = 0.12f; // зазор при подходе вплотную
float meleeLungeSpeed = 3.4f; // рывок в момент удара, юнитов/сек
float meleeLungeAccel = 120.0f; // рывок обязан успеть за 0.13 с
float meleeWindupScale = 0.20f; // множитель скорости на замахе
float meleeRecoverScale= 0.55f;
float meleeKnockback = 2.4f; // импульс мишени, юнитов/сек
// СВОЙ круг бойца: его отпускают от диска дальше, чем стрелка.
float meleeCohesionRadius = 5.6f;
float meleeSeekRadius = 7.0f; // дальше этого боец за целью не идёт вообще
// Штраф за уже занятую цель, в юнитах дистанции. Двое с клинками, вцепившиеся
// в одного, половину взмахов обрывают друг об друга — пусть расходятся.
float meleeClaimPenalty = 3.0f;
// Сколько держится связка после удара. Не успел продолжить — начинаешь
// серию заново с первого удара.
float meleeComboWindow = 1.10f;
// мишени
float targetHP = 500.0f;
float targetRespawn = 3.0f;
float targetRadius = 0.34f;
float targetKnockDamp = 9.0f; // гашение импульса от удара
float targetReturnRate= 5.0f; // возврат на своё место
// Общий множитель сопротивления отбросу. Само сопротивление у каждой мишени
// своё (sim/target.cpp): тяжёлую клинком не сдвинуть, лёгкая улетает.
float knockResistScale = 1.0f;
// отряд
float holdDelay = 0.22f; // сек без ввода -> HOLDING
float cohesionRadius = 2.1f; // стрелки держатся кучнее
float minEngageDist = 1.6f; // отмасштабировано под дальность 7.2
float formationSpacing = 1.0f; // строй плотнее
float cameraSmooth = 9.0f;
float anchorRadius = 0.3f; // якорь тоже скользит вдоль стен
// Диск отзывчивее бойцов: команда читается мгновенно, а догоняют её ногами.
float anchorSpeed = 3.3f;
float anchorSpeedFast = 4.8f;
float anchorAccel = 20.0f; // разгон/торможение курсора, юнитов/сек^2
// Поводок УПРУГИЙ, а не жёсткий: без него быстрый диск просто отрывается от
// отряда и теряет смысл, но жёсткий упор намертво фиксирует дистанцию, и по
// ней уже не отличить «подправил направление» от «тяну изо всех сил».
// anchorLeash — мягкая граница: до неё курсор идёт свободно;
// anchorLeashMax — жёсткий предел: страховка, до неё обычно не доходит;
// leashDrag — насколько тяжелеет курсор между границами (0..1).
float anchorLeash = 3.0f;
float anchorLeashMax = 5.0f;
float leashDrag = 0.75f; // на жёсткой границе остаётся 25% скорости
// --- натяжение поводка (см. docs/05-squad-ai.md) --------------------------
// Курсор упёрся в поводок, а игрок продолжает давить -> он хочет идти
// быстрее, чем отряд шагает. Это и есть команда «бегом»: отряд ускоряется,
// строй растягивается, а на верхней трети шкалы огонь прекращается вовсе.
float urgeStartFrac = 0.50f; // с какой доли поводка начинается натяжение
float urgeAttack = 2.6f; // как быстро натяжение набирается
float urgeRelease = 3.4f; // и как быстро отпускает
float sprintAt = 0.85f; // выше — спринт: бежим, не атакуем.
// Порог высокий намеренно: при удержании направления натяжение садится
// примерно на 0.54, то есть отряд идёт быстрым шагом И ПРОДОЛЖАЕТ работать
// по целям. Спринт остаётся тем, чем должен быть, — отдельным решением
// (Shift) или следствием того, что курсор реально уехал.
// --- дыхание строя --------------------------------------------------------
float holdSpacing = 0.90f; // на стоянке отряд собирается
float sprintSpacing = 1.30f; // в беге расходится
float sprintStretch = 1.55f; // и вытягивается вдоль движения
float formMorphRate = 4.0f; // скорость перестроения, 1/сек
// решатель огневых позиций (6.5)
float solveInterval = 0.25f;
float commitTime = 0.6f;
float hysteresis = 15.0f;
float ring1 = 0.7f; // кольца решателя сжаты вслед за строем
float ring2 = 1.4f;
float ring3 = 2.1f;
float candMinAlly = 0.60f; // минимум до другого агента (было МЕНЬШЕ диаметра тела)
float sLaneClear = 100.0f;
float sCoverage = 25.0f;
float sBlockAlly = 35.0f;
float sDistCost = 9.0f;
float sCohesionCost = 24.0f; // отбегать от отряда дорого
float sTooClose = 60.0f;
// Жёсткая граница огневой позиции: дальше cohesionRadius + postLeash от диска
// кандидат просто отбраковывается. Одной цены мало — линия огня стоит 100
// очков, и за неё решатель готов уйти сколько угодно далеко. А отряд, из
// которого стрелок ушёл на пять метров, отрядом быть перестаёт.
float postLeash = 1.0f;
// расступание из чужих линий (6.6)
float laneAvoidStrength = 7.0f;
// Ширина РЕЗЕРВА линии относительно порога «тело в линии» из 6.3.
// >1 означает: агента отжимает ДО того, как он влез в чужой сектор.
// Правило стрельбы (6.3) от этого не меняется — см. docs/08-decisions.md.
float laneReserveScale = 2.0f;
// ручное прицеливание (6.8)
float aimConeDeg = 20.0f;
float aimTurnRateDeg = 110.0f; // прицел ведётся тяжелее
// метрики
float uptimeWindow = 10.0f; // окно FIRE UPTIME, сек
float settleQuorum = 4.0f; // сколько агентов нужно для SETTLE TIME
// --- свет и туман войны (render-only, на симуляцию не влияет) -----------
float sightRadius = 7.6f; // где обзор гаснет полностью
float sightFullRadius = 2.2f; // дальше сразу начинается спад: свет должен таять
float sightConeDeg = 100.0f; // конус взгляда агента
float sightConeSoftDeg = 22.0f; // мягкий край конуса
float sightPersonalRadius= 1.9f; // круговой «личный» ореол вокруг агента
float sightBrightness = 0.86f; // яркость видимого; остаток отдан вспышкам
float fogMemoryLevel = 0.13f; // как тускло помнится уже увиденное
float fogWallBleed = 0.92f; // затухание света вглубь блока стен
float fogFadeRate = 9.0f; // сглаживание во времени, чтобы не щёлкало
float fogEntityReveal = 0.30f; // ниже этого света сущность не показывается
float eyeRevealLight = 0.05f; // глаз мишени тлеет почти в полной темноте
float eyeRadiusPx = 3.0f;
float eyeLiftPx = 11.0f; // на уровне «головы» капсулы
float agentMinLight = 0.34f; // свои читаемы всегда: игрок обязан их различать
// вспышки выстрелов как источники света
float flashPower = 0.55f;
float flashRadius = 4.0f;
float flashTime = 0.11f;
// рампа освещения (меняет саму таблицу — требует перепечь)
float lightGamma = 1.45f;
float lightMaxGain = 1.25f;
// --- эффекты стрельбы ---------------------------------------------------
float fxIntensity = 1.0f;
float tracerLength = 1.30f; // длина светящегося штриха, юнитов мира
float bulletLiftPx = 8.0f; // пуля летит на уровне груди, не по земле
float muzzleTime = 0.07f;
float muzzleRadiusPx = 5.0f;
float sparkTime = 0.16f;
float sparkRadiusPx = 4.0f;
float bladeLiftPx = 9.0f; // дуга метёт на уровне груди, не по земле
float bladeTrailFrac = 0.80f; // какая доля пройденной дуги ещё светится
float meleeShake = 1.4f; // отдача камеры на попадание клинком
// --- напряжение и постобработка -----------------------------------------
float dreadRadius = 7.0f; // с какой дистанции мишень начинает пугать
float dreadAttack = 6.0f; // страх набегает быстро
float dreadRelease = 0.5f; // отпускает медленно
float vigStartCalm = 0.42f; // где начинается виньетка
float vigStartTense = 0.20f;
float vigDepthCalm = 0.55f; // насколько темнеет край
float vigDepthTense = 0.85f;
float grainCalm = 6.0f; // амплитуда зерна (макс 127)
float grainTense = 16.0f;
float heartBpmCalm = 58.0f;
float heartBpmTense = 132.0f;
float heartAmp = 0.10f; // насколько сердцебиение давит картинку
float shotFlash = 0.12f; // подсветка кадра на выстрел
float shakePerShot = 0.55f; // отдача камеры, пикселей на выстрел
float shakeMax = 3.0f;
float shakeDecay = 9.0f;
// --- кровь (render-only, декали запекаются в спрайты пола) ---------------
float bloodAmount = 1.0f; // общий множитель размера пятен, 0 = выключить
float bloodRadiusHit = 0.26f; // пуля
float bloodRadiusMelee = 0.50f; // клинок
float bloodRadiusKill = 0.78f; // смерть: лужа
float bloodDrops = 5.0f; // сколько брызг летит по направлению удара
float bloodOpacity = 0.92f; // насколько густо пятно перекрывает пол
// --- дымка и пепел в воздухе (render-only) --------------------------------
float hazeAmount = 0.34f; // сила дымки, 0 = выключить
float hazeScale = 1.0f; // крупность клубов
float hazeDrift = 0.05f; // скорость сноса, экранных ячеек/сек
float hazeGate = 24.0f; // ниже этой яркости дымки нет: тьма остаётся тьмой
float ashDensity = 0.75f; // пылинок на тайл
float ashSpeed = 0.30f; // скорость дрейфа пепла
float ashBrightness = 0.60f;
float ashLiftPx = 13.0f; // на какой высоте висит взвесь
// флаги
bool freeFireSecondary = true; // 6.8: стрелять по вторичным целям в движении
bool friendlyFire = true; // 6.9: всегда true, тумблер только для проверки
};
// --- связка ударов ближнего боя ----------------------------------------------
//
// Чередование «влево-вправо» — это не связка, а метроном: два удара, между
// которыми нет разницы ни на глаз, ни по последствиям. Связка обязана иметь
// ФОРМУ: быстрый вход, злой возврат, тяжёлое завершение.
//
// Множители, а не абсолютные числа: базовые характеристики клинка остаются
// одним набором в Tuning и продолжают крутиться на ходу, а связка их гнёт.
struct MeleeComboStep
{
const char* name;
float arcMul; // ширина сектора
float dmgMul;
float knockMul;
float windupMul; // замах — телеграф; у тяжёлого удара он длиннее
float strikeMul; // активное окно
float recoverMul; // и расплата после
float intervalMul; // пауза до следующего удара связки
bool flipSide; // менять сторону захода лезвия
};
inline const MeleeComboStep MELEE_COMBO[] = {
// 1. Косой заход. Обычный удар, с него всё начинается.
{"slash", 1.00f, 1.00f, 1.00f, 1.00f, 1.00f, 1.00f, 1.00f, true},
// 2. Возврат с обратной стороны. Быстрее и почти без паузы — именно это
// читается как «связка», а не как два отдельных удара.
{"backhand",0.85f, 1.00f, 0.85f, 0.55f, 0.80f, 0.70f, 0.50f, true},
// 3. Раскрутка. Широкая, тяжёлая, с долгим восстановлением: в тесноте её
// просто не дадут начать — сектор не будет чист, и это правильная цена.
{"spin", 1.70f, 1.75f, 2.30f, 1.50f, 1.40f, 1.70f, 1.35f, false},
};
inline constexpr int MELEE_COMBO_STEPS = int(sizeof(MELEE_COMBO) / sizeof(MELEE_COMBO[0]));
inline Tuning g_tune;
// Максимально возможная скорость агента НА ХОДУ. Проверка спуска на время полёта
// пули обязана считать по НЕЙ, иначе рывок союзника не учитывается.
//
// Рывок бойца ближнего боя сюда намеренно не входит: он не «манёвр», а прямой
// отрезок с уже известным направлением, и AllyInterceptsShot проверяет его
// точно — отдельной экстраполяцией (см. ai/firing_solver.cpp).
inline float MaxAgentSpeed() { return std::max(g_tune.moveSpeed, g_tune.moveSpeedFast); }
// --- таблица параметров для подкрутки в оверлее ------------------------------
struct TunableParam
{
const char* name;
float Tuning::* member;
float step;
float minV;
float maxV;
};
inline const TunableParam TUNABLES[] = {
{"safetyMargin", &Tuning::safetyMargin, 0.01f, 0.0f, 1.0f},
{"laneHalfWidth", &Tuning::laneHalfWidth, 0.01f, 0.0f, 0.5f},
{"bodyRadius", &Tuning::bodyRadius, 0.01f, 0.1f, 0.8f},
{"wLaneAvoid", &Tuning::wLaneAvoid, 0.1f, 0.0f, 10.0f},
{"laneAvoidStr", &Tuning::laneAvoidStrength,0.25f, 0.0f, 30.0f},
{"laneReserve", &Tuning::laneReserveScale, 0.05f, 1.0f, 4.0f},
{"predictSlack", &Tuning::shotPredictSlack, 0.05f, 0.0f, 2.0f},
{"wSeparation", &Tuning::wSeparation, 0.1f, 0.0f, 10.0f},
{"steerAccel", &Tuning::steerAccel, 1.0f, 2.0f, 60.0f},
{"moveSpeed", &Tuning::moveSpeed, 0.1f, 0.5f, 10.0f},
{"moveSpeedFast", &Tuning::moveSpeedFast, 0.1f, 0.5f, 12.0f},
{"weaponRange", &Tuning::weaponRange, 0.25f, 2.0f, 20.0f},
{"fireInterval", &Tuning::fireInterval, 0.01f, 0.05f, 2.0f},
{"bulletSpeed", &Tuning::bulletSpeed, 1.0f, 5.0f, 60.0f},
{"solveInterval", &Tuning::solveInterval, 0.05f, 0.05f, 2.0f},
{"commitTime", &Tuning::commitTime, 0.05f, 0.0f, 3.0f},
{"hysteresis", &Tuning::hysteresis, 1.0f, 0.0f, 100.0f},
{"cohesionRadius", &Tuning::cohesionRadius, 0.1f, 1.0f, 12.0f},
{"minEngageDist", &Tuning::minEngageDist, 0.1f, 0.0f, 8.0f},
{"sLaneClear", &Tuning::sLaneClear, 5.0f, 0.0f, 400.0f},
{"sCoverage", &Tuning::sCoverage, 1.0f, 0.0f, 200.0f},
{"sBlockAlly", &Tuning::sBlockAlly, 1.0f, 0.0f, 200.0f},
{"sDistCost", &Tuning::sDistCost, 0.5f, 0.0f, 100.0f},
{"sCohesionCost", &Tuning::sCohesionCost, 0.5f, 0.0f, 100.0f},
{"sTooClose", &Tuning::sTooClose, 2.0f, 0.0f, 300.0f},
{"postLeash", &Tuning::postLeash, 0.1f, 0.0f, 10.0f},
{"formSpacing", &Tuning::formationSpacing, 0.05f, 0.4f, 4.0f},
{"anchorSpeed", &Tuning::anchorSpeed, 0.1f, 0.5f, 12.0f},
{"anchorSpeedFast",&Tuning::anchorSpeedFast, 0.1f, 0.5f, 16.0f},
{"anchorLeash", &Tuning::anchorLeash, 0.1f, 0.2f, 12.0f},
{"anchorLeashMax", &Tuning::anchorLeashMax, 0.1f, 0.4f, 16.0f},
{"leashDrag", &Tuning::leashDrag, 0.05f, 0.0f, 1.0f},
{"anchorAccel", &Tuning::anchorAccel, 1.0f, 2.0f, 80.0f},
// --- натяжение и строй ---
{"urgeStart", &Tuning::urgeStartFrac, 0.05f, 0.0f, 0.95f},
{"urgeAttack", &Tuning::urgeAttack, 0.2f, 0.1f, 20.0f},
{"urgeRelease", &Tuning::urgeRelease, 0.2f, 0.1f, 20.0f},
{"sprintAt", &Tuning::sprintAt, 0.02f, 0.05f, 1.0f},
{"holdSpacing", &Tuning::holdSpacing, 0.02f, 0.4f, 2.0f},
{"sprintSpacing", &Tuning::sprintSpacing, 0.02f, 0.4f, 3.0f},
{"sprintStretch", &Tuning::sprintStretch, 0.05f, 0.5f, 4.0f},
{"formMorph", &Tuning::formMorphRate, 0.25f, 0.25f, 20.0f},
// --- разброс и перезарядка ---
{"spreadStand", &Tuning::spreadStandDeg, 0.1f, 0.0f, 20.0f},
{"spreadMove", &Tuning::spreadMoveDeg, 0.1f, 0.0f, 30.0f},
{"spreadRecoil", &Tuning::spreadRecoilDeg, 0.05f, 0.0f, 10.0f},
{"spreadRecMax", &Tuning::spreadRecoilMax, 0.1f, 0.0f, 20.0f},
{"spreadDecay", &Tuning::spreadRecoilDecay,0.2f, 0.1f, 40.0f},
{"spreadScale", &Tuning::spreadScale, 0.05f, 0.0f, 4.0f},
{"magazine", &Tuning::magazineRifle, 1.0f, 1.0f, 200.0f},
{"reloadTime", &Tuning::reloadRifle, 0.05f, 0.1f, 8.0f},
{"bulletKnock", &Tuning::bulletKnock, 0.05f, 0.0f, 6.0f},
{"knockResist", &Tuning::knockResistScale, 0.05f, 0.0f, 2.0f},
// --- ближний бой ---
{"meleeReach", &Tuning::meleeReach, 0.05f, 0.4f, 4.0f},
{"meleeArc", &Tuning::meleeArcDeg, 5.0f, 20.0f, 300.0f},
{"meleeWindup", &Tuning::meleeWindup, 0.01f, 0.02f, 1.0f},
{"meleeStrike", &Tuning::meleeStrike, 0.01f, 0.02f, 1.0f},
{"meleeRecover", &Tuning::meleeRecover, 0.01f, 0.0f, 1.5f},
{"meleeInterval", &Tuning::meleeInterval, 0.02f, 0.1f, 3.0f},
{"meleeDamage", &Tuning::meleeDamage, 5.0f, 1.0f, 999.0f},
{"meleeLunge", &Tuning::meleeLungeSpeed, 0.1f, 0.0f, 12.0f},
{"meleeKnock", &Tuning::meleeKnockback, 0.1f, 0.0f, 12.0f},
{"meleeCohesion", &Tuning::meleeCohesionRadius, 0.2f, 1.0f, 20.0f},
{"meleeAllyMargin",&Tuning::meleeAllyMargin, 0.02f, 0.0f, 1.0f},
{"meleePredict", &Tuning::meleeStartPredict,0.1f, 0.0f, 3.0f},
{"meleeReserve", &Tuning::meleeReserveFrac, 0.05f, 0.0f, 1.5f},
{"meleeShake", &Tuning::meleeShake, 0.1f, 0.0f, 6.0f},
// --- хоррор-часть ---
{"sightRadius", &Tuning::sightRadius, 0.25f, 2.0f, 24.0f},
{"sightCone", &Tuning::sightConeDeg, 5.0f, 20.0f, 360.0f},
{"sightPersonal", &Tuning::sightPersonalRadius, 0.1f, 0.5f, 10.0f},
{"fogMemory", &Tuning::fogMemoryLevel, 0.02f, 0.0f, 1.0f},
{"fogReveal", &Tuning::fogEntityReveal, 0.02f, 0.0f, 1.0f},
{"flashPower", &Tuning::flashPower, 0.05f, 0.0f, 2.0f},
{"lightGamma", &Tuning::lightGamma, 0.05f, 0.3f, 4.0f},
{"lightGain", &Tuning::lightMaxGain, 0.05f, 0.2f, 3.0f},
{"fxIntensity", &Tuning::fxIntensity, 0.1f, 0.0f, 4.0f},
{"tracerLength", &Tuning::tracerLength, 0.1f, 0.1f, 6.0f},
{"grainTense", &Tuning::grainTense, 1.0f, 0.0f, 100.0f},
{"vigDepthTense", &Tuning::vigDepthTense, 0.05f, 0.0f, 1.0f},
{"dreadRadius", &Tuning::dreadRadius, 0.5f, 1.0f, 20.0f},
{"shakePerShot", &Tuning::shakePerShot, 0.05f, 0.0f, 4.0f},
// --- кровь, дымка, пепел ---
{"bloodAmount", &Tuning::bloodAmount, 0.05f, 0.0f, 3.0f},
{"bloodOpacity", &Tuning::bloodOpacity, 0.02f, 0.0f, 1.0f},
{"bloodDrops", &Tuning::bloodDrops, 1.0f, 0.0f, 16.0f},
{"hazeAmount", &Tuning::hazeAmount, 0.02f, 0.0f, 2.0f},
{"hazeScale", &Tuning::hazeScale, 0.05f, 0.2f, 4.0f},
{"hazeDrift", &Tuning::hazeDrift, 0.01f, 0.0f, 1.0f},
{"hazeGate", &Tuning::hazeGate, 1.0f, 0.0f, 128.0f},
{"ashDensity", &Tuning::ashDensity, 0.05f, 0.0f, 4.0f},
{"ashSpeed", &Tuning::ashSpeed, 0.05f, 0.0f, 3.0f},
{"ashBright", &Tuning::ashBrightness, 0.05f, 0.0f, 2.0f},
{"ashLift", &Tuning::ashLiftPx, 1.0f, 0.0f, 40.0f},
};
inline constexpr int TUNABLE_COUNT = int(sizeof(TUNABLES) / sizeof(TUNABLES[0]));

205
src/world/tilemap.cpp Normal file
View File

@ -0,0 +1,205 @@
#include "world/tilemap.h"
#include <limits>
namespace
{
constexpr float INF_F = std::numeric_limits<float>::infinity();
} // namespace
// -----------------------------------------------------------------------------
// Уровень. Всё захардкожено, ни одного случайного числа — карта детерминирована.
//
// y 0..12 — северная комната (кучка мишеней)
// y 13..15 — стена-разделитель с двумя проходами шириной 2 тайла
// y 16..45 — центральная арена + западная и восточная комнаты за стенами
// -----------------------------------------------------------------------------
void Tilemap::Generate()
{
for (int i = 0; i < MAP_W * MAP_H; ++i) tiles[i] = Tile::EMPTY;
auto fill = [&](int x0, int y0, int x1, int y1, Tile t) {
for (int y = y0; y <= y1; ++y)
for (int x = x0; x <= x1; ++x)
Set(x, y, t);
};
auto column = [&](int x, int y) { Set(x, y, Tile::WALL); };
// --- сплошная стена по периметру (2 тайла) ---
fill(0, 0, MAP_W - 1, 1, Tile::WALL);
fill(0, MAP_H - 2, MAP_W - 1, MAP_H - 1, Tile::WALL);
fill(0, 0, 1, MAP_H - 1, Tile::WALL);
fill(MAP_W - 2, 0, MAP_W - 1, MAP_H - 1, Tile::WALL);
// --- северная стена-разделитель, два прохода шириной 2 ---
fill(2, 13, 45, 15, Tile::WALL);
fill(10, 13, 11, 15, Tile::EMPTY);
fill(30, 13, 31, 15, Tile::EMPTY);
// --- западная стена-разделитель, проход шириной 2 ---
fill(12, 20, 14, 44, Tile::WALL);
fill(12, 30, 14, 31, Tile::EMPTY);
// --- восточная стена-разделитель, проход шириной 2 ---
fill(38, 18, 40, 40, Tile::WALL);
fill(38, 27, 40, 28, Tile::EMPTY);
// --- колонны и огрызки стен на арене (без них не проверить LOS) ---
const int arenaCols[][2] = {
{18, 19}, {18, 27}, {18, 34}, {24, 19}, {24, 34},
{30, 28}, {30, 34}, {26, 24}, {21, 23}, {35, 22}, {35, 36},
};
for (const auto& c : arenaCols) column(c[0], c[1]);
fill(20, 30, 22, 30, Tile::WALL); // огрызок-стенка поперёк
fill(27, 38, 27, 40, Tile::WALL); // огрызок вдоль
fill(33, 30, 34, 31, Tile::WALL); // блок 2x2
// --- северная комната ---
const int northCols[][2] = {{8, 8}, {14, 5}, {30, 9}, {38, 6}, {42, 10}, {17, 10}, {35, 4}};
for (const auto& c : northCols) column(c[0], c[1]);
fill(21, 9, 23, 9, Tile::WALL);
// --- западная комната ---
const int westCols[][2] = {{5, 25}, {8, 30}, {4, 35}, {10, 38}, {6, 21}};
for (const auto& c : westCols) column(c[0], c[1]);
fill(4, 28, 4, 30, Tile::WALL);
// --- восточная комната ---
const int eastCols[][2] = {{43, 22}, {42, 30}, {44, 38}};
for (const auto& c : eastCols) column(c[0], c[1]);
// --- южная полоса ---
const int southCols[][2] = {{20, 43}, {28, 42}, {34, 44}, {16, 41}};
for (const auto& c : southCols) column(c[0], c[1]);
}
// -----------------------------------------------------------------------------
// DDA Amanatides & Woo.
// -----------------------------------------------------------------------------
bool Tilemap::RaycastBlocked(Vec2 a, Vec2 b, float* tHit) const
{
int x = int(std::floor(a.x));
int y = int(std::floor(a.y));
if (IsWall(x, y)) { if (tHit) *tHit = 0.0f; return true; }
const Vec2 d = b - a;
const float len = Length(d);
if (len < 1e-6f) return false;
const Vec2 dir = d / len;
const int stepX = (dir.x > 0.0f) ? 1 : ((dir.x < 0.0f) ? -1 : 0);
const int stepY = (dir.y > 0.0f) ? 1 : ((dir.y < 0.0f) ? -1 : 0);
const float tDeltaX = (stepX != 0) ? std::fabs(1.0f / dir.x) : INF_F;
const float tDeltaY = (stepY != 0) ? std::fabs(1.0f / dir.y) : INF_F;
float tMaxX = INF_F;
float tMaxY = INF_F;
if (stepX > 0) tMaxX = (float(x + 1) - a.x) / dir.x;
else if (stepX < 0) tMaxX = (float(x) - a.x) / dir.x;
if (stepY > 0) tMaxY = (float(y + 1) - a.y) / dir.y;
else if (stepY < 0) tMaxY = (float(y) - a.y) / dir.y;
const int endX = int(std::floor(b.x));
const int endY = int(std::floor(b.y));
for (;;)
{
if (x == endX && y == endY) return false;
float tEnter;
if (tMaxX < tMaxY)
{
if (tMaxX > len) return false;
tEnter = tMaxX;
x += stepX;
tMaxX += tDeltaX;
}
else
{
if (tMaxY > len) return false;
tEnter = tMaxY;
y += stepY;
tMaxY += tDeltaY;
}
if (IsWall(x, y))
{
if (tHit) *tHit = Clampf(tEnter / len, 0.0f, 1.0f);
return true;
}
}
}
// -----------------------------------------------------------------------------
// Circle vs AABB тайлов: выталкивание по кратчайшему направлению.
// -----------------------------------------------------------------------------
void Tilemap::ResolveCircle(Vec2& p, float r) const
{
for (int iter = 0; iter < 3; ++iter)
{
bool moved = false;
const int x0 = int(std::floor(p.x - r));
const int x1 = int(std::floor(p.x + r));
const int y0 = int(std::floor(p.y - r));
const int y1 = int(std::floor(p.y + r));
for (int ty = y0; ty <= y1; ++ty)
{
for (int tx = x0; tx <= x1; ++tx)
{
if (!IsWall(tx, ty)) continue;
const Vec2 closest{Clampf(p.x, float(tx), float(tx + 1)),
Clampf(p.y, float(ty), float(ty + 1))};
Vec2 delta = p - closest;
const float dist = Length(delta);
if (dist > 1e-5f)
{
if (dist >= r) continue;
p += (delta / dist) * (r - dist);
moved = true;
}
else
{
// центр внутри тайла — выталкиваем по ближайшей грани
const float dl = p.x - float(tx);
const float dr = float(tx + 1) - p.x;
const float dt = p.y - float(ty);
const float db = float(ty + 1) - p.y;
const float m = std::min(std::min(dl, dr), std::min(dt, db));
if (m == dl) p.x = float(tx) - r;
else if (m == dr) p.x = float(tx + 1) + r;
else if (m == dt) p.y = float(ty) - r;
else p.y = float(ty + 1) + r;
moved = true;
}
}
}
if (!moved) break;
}
}
bool Tilemap::CircleHitsWall(Vec2 p, float r) const
{
const int x0 = int(std::floor(p.x - r));
const int x1 = int(std::floor(p.x + r));
const int y0 = int(std::floor(p.y - r));
const int y1 = int(std::floor(p.y + r));
for (int ty = y0; ty <= y1; ++ty)
{
for (int tx = x0; tx <= x1; ++tx)
{
if (!IsWall(tx, ty)) continue;
const Vec2 closest{Clampf(p.x, float(tx), float(tx + 1)),
Clampf(p.y, float(ty), float(ty + 1))};
if (DistanceSq(p, closest) < r * r) return true;
}
}
return false;
}

42
src/world/tilemap.h Normal file
View File

@ -0,0 +1,42 @@
#pragma once
// -----------------------------------------------------------------------------
// Карта тайлов: EMPTY / WALL. Декартов мир, тайл = 1.0 юнита.
// Здесь же DDA-рейкаст (Amanatides & Woo) и circle-vs-AABB разрешение коллизий.
// Об изометрии этот модуль не знает.
// -----------------------------------------------------------------------------
#include <cstdint>
#include "core/math.h"
#include "tuning.h"
enum class Tile : uint8_t
{
EMPTY = 0,
WALL = 1,
};
struct Tilemap
{
Tile tiles[MAP_W * MAP_H] = {};
void Generate(); // единственный захардкоженный уровень
static bool InBounds(int x, int y) { return unsigned(x) < unsigned(MAP_W) && unsigned(y) < unsigned(MAP_H); }
Tile At(int x, int y) const { return InBounds(x, y) ? tiles[y * MAP_W + x] : Tile::WALL; }
void Set(int x, int y, Tile t) { if (InBounds(x, y)) tiles[y * MAP_W + x] = t; }
// Вне карты считаем стеной — так рейкасты и коллизии не убегают наружу.
bool IsWall(int x, int y) const { return At(x, y) == Tile::WALL; }
bool IsWallAt(Vec2 p) const { return IsWall(int(std::floor(p.x)), int(std::floor(p.y))); }
// Amanatides & Woo. true, если отрезок a->b пересекает стену. Без аллокаций.
// tHit (если нужен) — параметр входа в стену вдоль отрезка, [0..1].
bool RaycastBlocked(Vec2 a, Vec2 b, float* tHit = nullptr) const;
// Выталкивание круга из стен (несколько итераций — из-за углов).
void ResolveCircle(Vec2& p, float r) const;
// Есть ли стена в круге радиуса r вокруг p (для отбраковки кандидатов).
bool CircleHitsWall(Vec2 p, float r) const;
};