commit 7880f275bd022df771f700341c668339e0aa030d Author: z.kirill Date: Mon Aug 3 13:00:49 2026 +0300 Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7e64f2c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Auto detect text files and perform LF normalization +* text=auto +Makefile linguist-generated=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6767c2e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.vscode/.gitkeep b/.vscode/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json new file mode 100644 index 0000000..a0c13b3 --- /dev/null +++ b/.vscode/c_cpp_properties.json @@ -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 +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4b8f3f2 --- /dev/null +++ b/.vscode/launch.json @@ -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" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e98318a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/*.o": true, + "**/*.exe": true, + } +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..455d733 --- /dev/null +++ b/.vscode/tasks.json @@ -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": "Прогон без окна с метриками" + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e49a9b8 --- /dev/null +++ b/CLAUDE.md @@ -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) | оверлей F1–F4, метрики, runtime-тюнинг, `--set` | отладка и балансировка | +| [docs/07-milestones.md](docs/07-milestones.md) | журнал вех M0–M6: что сделано и чем проверено | история и статус | +| [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&` — эффекты физически не могут влиять на симуляцию. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..401b6e9 --- /dev/null +++ b/CMakeLists.txt @@ -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") diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7c104a1 --- /dev/null +++ b/Makefile @@ -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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..391606b --- /dev/null +++ b/README.md @@ -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. diff --git a/SQUAD_PROTOTYPE_SPEC.md b/SQUAD_PROTOTYPE_SPEC.md new file mode 100644 index 0000000..affce52 --- /dev/null +++ b/SQUAD_PROTOTYPE_SPEC.md @@ -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 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.3–6.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 реализуется как написано. Если считаешь, что где-то ошибка — сначала скажи, потом делай. diff --git a/docs/00-overview.md b/docs/00-overview.md new file mode 100644 index 0000000..ed913cb --- /dev/null +++ b/docs/00-overview.md @@ -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.1–M7.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). diff --git a/docs/01-build-and-run.md b/docs/01-build-and-run.md new file mode 100644 index 0000000..49ebc08 --- /dev/null +++ b/docs/01-build-and-run.md @@ -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). diff --git a/docs/02-architecture.md b/docs/02-architecture.md new file mode 100644 index 0000000..42ab24e --- /dev/null +++ b/docs/02-architecture.md @@ -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 оверлей F1–F4 поверх кадра (единственное место с 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). diff --git a/docs/03-rendering.md b/docs/03-rendering.md new file mode 100644 index 0000000..256e12f --- /dev/null +++ b/docs/03-rendering.md @@ -0,0 +1,102 @@ +# 03 — Рендер: софтверный буфер, изометрия, спрайты + +## Фреймбуфер + +[src/render/framebuffer.h](../src/render/framebuffer.h) — `std::vector` 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. стена никогда не темнее самого яркого соседа-пола — иначе сквозь пропущенный + тёмный блок проступит освещённый пол тайла (tx−1, ty−1), который рисуется + раньше по глубине. diff --git a/docs/04-world.md b/docs/04-world.md new file mode 100644 index 0000000..c8315c5 --- /dev/null +++ b/docs/04-world.md @@ -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.5–8.5 → 7.5–10.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`): до +ближнего боя агент мог стоять внутри мишени, и удар не читался вообще. diff --git a/docs/05-squad-ai.md b/docs/05-squad-ai.md new file mode 100644 index 0000000..a7b8c66 --- /dev/null +++ b/docs/05-squad-ai.md @@ -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°, отсортированные по дальности от якоря. Все агенты +приоритезируют назначенную цель. + +Правила 1–8 при этом **не меняются вообще**. Ручной режим никогда не позволяет +стрелять сквозь своих — тот же `LaneClearFrom` на том же спуске. diff --git a/docs/06-debug-and-tuning.md b/docs/06-debug-and-tuning.md new file mode 100644 index 0000000..c5659ec --- /dev/null +++ b/docs/06-debug-and-tuning.md @@ -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.4–3.5 мс при бюджете 16.6 мс. diff --git a/docs/07-milestones.md b/docs/07-milestones.md new file mode 100644 index 0000000..41b98c8 --- /dev/null +++ b/docs/07-milestones.md @@ -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** | оверлей F1–F4, метрики, журнал попаданий по своим, runtime-тюнинг `[ ] - =`, `--set` | скриншот боевого кадра: зелёные линии у всех пятерых, FRIENDLY_HITS 0, uptime 99 % | +| **M7** | ближний бой: классы оружия, сектор удара, три фазы, свой больший круг, дуга; отзывчивый диск на поводке | `--accept`: M7.1–M7.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). diff --git a/docs/08-decisions.md b/docs/08-decisions.md new file mode 100644 index 0000000..2342ee2 --- /dev/null +++ b/docs/08-decisions.md @@ -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 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 с. +- Пути агентов чисто локальные, без поиска пути. Отряд проходит узкие коридоры, + но лабиринт с тупиками не осилит — это вне рамок прототипа. diff --git a/docs/09-acceptance.md b/docs/09-acceptance.md new file mode 100644 index 0000000..b7d7096 --- /dev/null +++ b/docs/09-acceptance.md @@ -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 меряется на открытой арене чистым отрядом + стрелков, где такой ситуации нет по построению. diff --git a/docs/10-atmosphere.md b/docs/10-atmosphere.md new file mode 100644 index 0000000..6e638f7 --- /dev/null +++ b/docs/10-atmosphere.md @@ -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», и сквозь её силуэт проступит освещённый +пол тайла (tx−1, ty−1), который рисуется раньше по глубине. + +### Конус зрения и ИИ + +Конус — **чисто игроцкая условность**. ИИ по-прежнему работает по 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.4–3.5 мс** на кадр при бюджете 16.6 мс, +то есть 8–21 %. Верхняя граница — когда память карты уже разведана и освещённых +тайлов много; fps при этом держится 60. Симуляция — 1.9 мкс. Ни одна из оптимизаций (предзатенённые +спрайты, пересчёт видимости через кадр, уменьшение числа ступеней рампы) не нужна. + +Самое дорогое — не пост-проход, а `BlitShaded` сцены. Пропуск полностью тёмных +тайлов это частично компенсирует: в тумане рисуется заметно меньше половины карты. + +## Аварийные тумблеры + +`F5` — выключить весь хоррор-слой (свет, туман, пост) и вернуть прежний кадр. +`F6` — показать диски обзора. Полезно, когда непонятно, баг это или темнота. diff --git a/docs/11-melee.md b/docs/11-melee.md new file mode 100644 index 0000000..993405a --- /dev/null +++ b/docs/11-melee.md @@ -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.1–M7.3 в `--accept` — [09-acceptance.md](09-acceptance.md). + +## Куда приедут пушки и апгрейды + +[src/sim/loadout.h](../src/sim/loadout.h). Сейчас профиль ровно один на класс и +целиком копируется из `tuning.h` каждый шаг (`SyncLoadoutFromTuning`) — так +работает runtime-подкрутка. Когда профилей станет несколько, здесь встанет +таблица и модификаторы поверх неё, а весь остальной код продолжит читать +`Agent::weapon` / `Agent::melee` и не заметит разницы. + +Что уже вынесено в параметры и годится под модификаторы: досягаемость, ширина +сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание, +скорость и ускорение рывка, множители скорости на замахе и восстановлении, +радиус своего круга. diff --git a/docs/combat.png b/docs/combat.png new file mode 100644 index 0000000..886426f Binary files /dev/null and b/docs/combat.png differ diff --git a/lib/libgcc_s_dw2-1.dll b/lib/libgcc_s_dw2-1.dll new file mode 100644 index 0000000..1b79afc Binary files /dev/null and b/lib/libgcc_s_dw2-1.dll differ diff --git a/lib/libstdc++-6.dll b/lib/libstdc++-6.dll new file mode 100644 index 0000000..21f9ea6 Binary files /dev/null and b/lib/libstdc++-6.dll differ diff --git a/main.code-workspace b/main.code-workspace new file mode 100644 index 0000000..63784b0 --- /dev/null +++ b/main.code-workspace @@ -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 + } +} \ No newline at end of file diff --git a/preview.jpg b/preview.jpg new file mode 100644 index 0000000..f10b300 Binary files /dev/null and b/preview.jpg differ diff --git a/src/ai/firing_solver.cpp b/src/ai/firing_solver.cpp new file mode 100644 index 0000000..7dcc410 --- /dev/null +++ b/src/ai/firing_solver.cpp @@ -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& 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& 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& 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& 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; +} diff --git a/src/ai/firing_solver.h b/src/ai/firing_solver.h new file mode 100644 index 0000000..f6d92fd --- /dev/null +++ b/src/ai/firing_solver.h @@ -0,0 +1,68 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Ядро прототипа (спек 6.3–6.5): проверка линии огня, выбор цели, +// решатель огневых позиций с гистерезисом. +// +// Всё здесь — чистые функции над состоянием мира. Модуль ai/ НЕ знает +// ни про рендер, ни про изометрию, ни про ввод. +// ----------------------------------------------------------------------------- +#include + +#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* 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); diff --git a/src/ai/lane_registry.cpp b/src/ai/lane_registry.cpp new file mode 100644 index 0000000..d65b14c --- /dev/null +++ b/src/ai/lane_registry.cpp @@ -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; +} diff --git a/src/ai/lane_registry.h b/src/ai/lane_registry.h new file mode 100644 index 0000000..a5bf90b --- /dev/null +++ b/src/ai/lane_registry.h @@ -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; +}; diff --git a/src/ai/melee.cpp b/src/ai/melee.cpp new file mode 100644 index 0000000..a8ee748 --- /dev/null +++ b/src/ai/melee.cpp @@ -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& 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; +} diff --git a/src/ai/melee.h b/src/ai/melee.h new file mode 100644 index 0000000..5af788d --- /dev/null +++ b/src/ai/melee.h @@ -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); diff --git a/src/core/hash.h b/src/core/hash.h new file mode 100644 index 0000000..36726ae --- /dev/null +++ b/src/core/hash.h @@ -0,0 +1,36 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Чистые хеш-функции без состояния. Значение зависит ТОЛЬКО от аргументов. +// +// Это не генератор случайных чисел: общего состояния нет, «посеять» нечего, +// последовательность вызовов ни на что не влияет. Поэтому такой хеш можно +// звать и из симуляции, не ломая детерминизм: --headless по-прежнему выдаёт +// один и тот же прогон, а разброс выстрела воспроизводится по номеру выстрела +// и индексу стрелка (см. sim/loadout.h и docs/08-decisions.md). +// +// Рендер использует те же функции через render/noise.h. +// ----------------------------------------------------------------------------- +#include + +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; } diff --git a/src/core/input.h b/src/core/input.h new file mode 100644 index 0000000..681483a --- /dev/null +++ b/src/core/input.h @@ -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 +}; diff --git a/src/core/math.h b/src/core/math.h new file mode 100644 index 0000000..10417f9 --- /dev/null +++ b/src/core/math.h @@ -0,0 +1,107 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Базовая математика прототипа. Только декартов мир (top-down), никакой изометрии. +// Header-only: всё inline, никаких аллокаций. +// ----------------------------------------------------------------------------- +#include +#include + +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); +} diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp new file mode 100644 index 0000000..5f85b47 --- /dev/null +++ b/src/debug/harness.cpp @@ -0,0 +1,603 @@ +#include "debug/harness.h" + +#include +#include +#include + +#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(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; +} diff --git a/src/debug/harness.h b/src/debug/harness.h new file mode 100644 index 0000000..b49a06c --- /dev/null +++ b/src/debug/harness.h @@ -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(); diff --git a/src/debug/metrics.cpp b/src/debug/metrics.cpp new file mode 100644 index 0000000..284166b --- /dev/null +++ b/src/debug/metrics.cpp @@ -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; +} diff --git a/src/debug/metrics.h b/src/debug/metrics.h new file mode 100644 index 0000000..34109a7 --- /dev/null +++ b/src/debug/metrics.h @@ -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); +}; diff --git a/src/debug/overlay.cpp b/src/debug/overlay.cpp new file mode 100644 index 0000000..abfb341 --- /dev/null +++ b/src/debug/overlay.cpp @@ -0,0 +1,307 @@ +#include "debug/overlay.h" + +#include + +#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}); +} diff --git a/src/debug/overlay.h b/src/debug/overlay.h new file mode 100644 index 0000000..33319f7 --- /dev/null +++ b/src/debug/overlay.h @@ -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; +}; diff --git a/src/ecs/ecs.h b/src/ecs/ecs.h new file mode 100644 index 0000000..4b54bb6 --- /dev/null +++ b/src/ecs/ecs.h @@ -0,0 +1,231 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Минимальная ECS: сущность — это идентификатор, данные лежат в типизированных +// плотных пулах, системы — свободные функции над пулами. +// +// Зачем она здесь, если прототип и без неё работал: +// * оружие стало отдельной СУЩНОСТЬЮ со своим набором статов (пистолет, +// карабин, клинок). Боец ссылается на неё, а не носит числа в себе — +// значит ствол можно подменить, не трогая ни бойца, ни правила боя; +// * мишени, пули и бойцы перестали быть тремя разными контейнерами в Game +// и стали тремя пулами одного мира. Добавить четвёртый вид сущности — +// это новый компонент, а не новое поле в Game и новый цикл в Step. +// +// Чего здесь НЕТ и почему: +// * архетипов и запросов вида View. Сущностей в прототипе десятки, +// а не десятки тысяч; выигрыш от архетипов ушёл бы в сложность, а цена +// промаха кеша на 5 агентах равна нулю; +// * реакции на изменение компонентов, планировщика систем, рефлексии. +// +// Гранулярность компонентов сознательно КРУПНАЯ (Agent, Target, Bullet целиком, +// а не Position+Velocity+Body по отдельности). Причина в docs/12-ecs.md: +// правила боя из спека читают состояние агента целиком и в одном порядке, +// и дробление их только запутало бы, не дав ни строчки полезного обобщения. +// +// Детерминизм: удаление — swap-remove, порядок пулов зависит только от истории +// вызовов. Одинаковая последовательность операций даёт одинаковый мир, поэтому +// --headless по-прежнему воспроизводим. +// ----------------------------------------------------------------------------- +#include +#include +#include + +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 +struct Pool final : IPool +{ + std::vector data; + std::vector owner; // кому принадлежит data[i] + std::vector 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 +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 + Pool& Store() + { + const uint32_t id = TypeId(); + if (id >= pools.size()) pools.resize(size_t(id) + 1); + if (!pools[id]) pools[id] = std::make_unique>(); + return *static_cast*>(pools[id].get()); + } + + template + const Pool& Store() const + { + return const_cast(this)->Store(); + } + + template + T& Add(Entity e, const T& value) { return Store().Add(e, value); } + + template + T* Get(Entity e) { return Store().Find(e); } + + template + const T* Get(Entity e) const { return Store().Find(e); } + + template + std::vector& All() { return Store().data; } + + void Clear() + { + for (auto& p : pools) + if (p) p->Clear(); + alive.clear(); + generation.clear(); + freeSlots.clear(); + } + +private: + std::vector alive; + std::vector generation; + std::vector freeSlots; + std::vector> pools; +}; + +} // namespace ecs diff --git a/src/game.cpp b/src/game.cpp new file mode 100644 index 0000000..ec263ef --- /dev/null +++ b/src/game.cpp @@ -0,0 +1,557 @@ +#include "game.h" + +// Перевод экранных осей ввода в мировые — единственное место, +// где симуляционный слой касается изометрии (см. docs/02-architecture.md). +#include "render/iso.h" + +namespace +{ + +// Снять с мира все пули разом. Пуля — сущность, поэтому просто очистить вектор +// нельзя: остались бы живые идентификаторы без компонентов. +void ClearBullets(ecs::World& world) +{ + std::vector& bullets = world.All(); + while (!bullets.empty()) world.Destroy(bullets.back().self); +} + +} // namespace + +Game::Game() + : targets(world.Store().data), + bullets(world.Store().data) +{ +} + +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 = ↦ + 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; + } + } + } +} diff --git a/src/game.h b/src/game.h new file mode 100644 index 0000000..0c39d68 --- /dev/null +++ b/src/game.h @@ -0,0 +1,83 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Контейнер состояния прототипа: карта, мир ECS, отряд, мишени, пули, метрики. +// main.cpp занимается только окном и fixed timestep, вся склейка — здесь. +// Ввод приходит снаружи структурой InputState (нужно и для headless-прогонов). +// +// Мишени, пули, бойцы и оружие — сущности одного мира ecs::World. targets и +// bullets ниже — это ССЫЛКИ на плотные массивы соответствующих пулов, а не +// отдельные контейнеры: правила ходят по ним как по обычным векторам и про ECS +// ничего не знают (docs/12-ecs.md). +// ----------------------------------------------------------------------------- +#include + +#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& targets; // пул ECS + std::vector& 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); +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..8391a07 --- /dev/null +++ b/src/main.cpp @@ -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 + +#include +#include +#include +#include + +#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(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(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; +} diff --git a/src/render/atmos.cpp b/src/render/atmos.cpp new file mode 100644 index 0000000..79034f9 --- /dev/null +++ b/src/render/atmos.cpp @@ -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); + } + } + } +} diff --git a/src/render/atmos.h b/src/render/atmos.h new file mode 100644 index 0000000..62bc3ad --- /dev/null +++ b/src/render/atmos.h @@ -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; +}; diff --git a/src/render/blood.cpp b/src/render/blood.cpp new file mode 100644 index 0000000..f45ac11 --- /dev/null +++ b/src/render/blood.cpp @@ -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; +} diff --git a/src/render/blood.h b/src/render/blood.h new file mode 100644 index 0000000..49eed46 --- /dev/null +++ b/src/render/blood.h @@ -0,0 +1,56 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Кровь: вечные декали, ЗАПЕЧЁННЫЕ в поверхность. +// +// Почему не список пятен, которые дорисовываются каждый кадр: их число растёт +// без предела, и через минуту боя кадр начинает стоить столько же, сколько вся +// остальная сцена. Обычное лекарство — выбрасывать старые декали, но задача +// прямо противоположная: пятна должны остаться навсегда. +// +// Решение — запекание. Пол рисуется поштучно спрайтами-ромбами. При попадании +// мы один раз делаем КОПИЮ ромба того тайла, куда пришлось попадание, и рисуем +// кровь прямо в её пиксели. Дальше этот тайл рисуется ровно так же, как раньше, +// просто другим спрайтом. Стоимость кадра не меняется НИ НА ЧТО, сколько бы +// крови ни накопилось: цена платится один раз, в момент попадания. +// +// Свет и туман войны продолжают работать: запечённая кровь проходит через ту же +// световую рампу, что и пол, потому что она и есть пол. +// +// Потолок памяти — BLOOD_TILE_BUDGET тайлов (по 2 КБ). Когда он выбран, новые +// пятна ложатся только на уже запачканные тайлы: это видно как «кровь копится +// там, где дрались», а не как исчезающие декали. +// ----------------------------------------------------------------------------- +#include +#include + +#include "core/math.h" +#include "render/framebuffer.h" +#include "render/sprites.h" +#include "sim/events.h" +#include "world/tilemap.h" + +struct BloodField +{ + std::vector slotOf; // тайл -> индекс в stained, -1 = чистый + std::vector 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); +}; diff --git a/src/render/framebuffer.cpp b/src/render/framebuffer.cpp new file mode 100644 index 0000000..b4902e4 --- /dev/null +++ b/src/render/framebuffer.cpp @@ -0,0 +1,332 @@ +#include "render/framebuffer.h" + +#include + +#include +#include + +// ----------------------------------------------------------------------------- +// Световая рампа +// ----------------------------------------------------------------------------- +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(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(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); +} diff --git a/src/render/framebuffer.h b/src/render/framebuffer.h new file mode 100644 index 0000000..b6348c4 --- /dev/null +++ b/src/render/framebuffer.h @@ -0,0 +1,192 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Софтверный фреймбуфер: RGBA8, ручная запись пикселей. +// Ни один игровой пиксель не рисуется примитивами raylib. +// raylib используется только в ScreenBlitter — финальный блит на экран. +// +// Инварианты (нарушение любого ломает картинку молча): +// * альфа фреймбуфера всегда 255; +// * ступень 0 световой рампы даёт чистый чёрный и совпадает с цветом Clear; +// * спрайты пишутся с альфа-тестом, эффекты — аддитивно. +// ----------------------------------------------------------------------------- +#include +#include +#include + +// В памяти байты идут 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 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 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; +}; diff --git a/src/render/fx.cpp b/src/render/fx.cpp new file mode 100644 index 0000000..cf1ff4b --- /dev/null +++ b/src/render/fx.cpp @@ -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& 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); + } +} diff --git a/src/render/fx.h b/src/render/fx.h new file mode 100644 index 0000000..8d2a40b --- /dev/null +++ b/src/render/fx.h @@ -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& 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); + diff --git a/src/render/iso.h b/src/render/iso.h new file mode 100644 index 0000000..4591360 --- /dev/null +++ b/src/render/iso.h @@ -0,0 +1,93 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Изометрия существует ТОЛЬКО здесь и в рендере. Симуляция, ИИ и коллизии +// работают в декартовом мире (top-down) и об этом файле не знают. +// +// sx = (wx - wy) * 16 +// sy = (wx + wy) * 8 +// ----------------------------------------------------------------------------- +#include +#include + +#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& 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& 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 diff --git a/src/render/lighting.cpp b/src/render/lighting.cpp new file mode 100644 index 0000000..6d7bfe4 --- /dev/null +++ b/src/render/lighting.cpp @@ -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; +} diff --git a/src/render/lighting.h b/src/render/lighting.h new file mode 100644 index 0000000..be5774f --- /dev/null +++ b/src/render/lighting.h @@ -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]; + } +}; diff --git a/src/render/noise.h b/src/render/noise.h new file mode 100644 index 0000000..20703d0 --- /dev/null +++ b/src/render/noise.h @@ -0,0 +1,13 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Псевдослучайность рендера. +// +// Сами функции живут в core/hash.h: они понадобились и симуляции (разброс +// выстрела считается детерминированным хешем от номера выстрела, а не +// генератором). Здесь остался только вход для рендера, чтобы не расползались +// включения: зерно, дрожание, пепел и вспышки берут «случайность» отсюда. +// +// Состояния нет ни у кого: значение зависит только от аргументов (координата, +// номер кадра), общего генератора нет и синхронизировать нечего. +// ----------------------------------------------------------------------------- +#include "core/hash.h" diff --git a/src/render/post.cpp b/src/render/post.cpp new file mode 100644 index 0000000..57deae5 --- /dev/null +++ b/src/render/post.cpp @@ -0,0 +1,279 @@ +#include "render/post.h" + +#include + +#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); + } + } +} diff --git a/src/render/post.h b/src/render/post.h new file mode 100644 index 0000000..4b9913f --- /dev/null +++ b/src/render/post.h @@ -0,0 +1,67 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Настроение кадра: напряжение (Dread) и один слитый пост-проход по пикселям. +// +// Пост-проход НЕ занимается светом — свет уже вплавлен на блите спрайтов +// (см. render/lighting.h, почему иначе нельзя). Здесь только виньетка, зерно +// и тон, то есть то, что честно считается в экранном пространстве. +// ----------------------------------------------------------------------------- +#include +#include + +#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 vigD; // статичное поле виньетки, строится один раз + // Дымка: поле шума по узлам сетки, между узлами — билинейка на лету. + // Полноразмерное поле считать незачем: клубы всё равно низкочастотные. + std::vector 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); +}; diff --git a/src/render/scene.cpp b/src/render/scene.cpp new file mode 100644 index 0000000..bfa4881 --- /dev/null +++ b/src/render/scene.cpp @@ -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)); + } +} diff --git a/src/render/scene.h b/src/render/scene.h new file mode 100644 index 0000000..69d5cf4 --- /dev/null +++ b/src/render/scene.h @@ -0,0 +1,46 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Сборка кадра: сплошной проход по полу, затем общий список стен и сущностей, +// отсортированный по глубине (wx + wy). +// ----------------------------------------------------------------------------- +#include + +#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 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); diff --git a/src/render/sprites.cpp b/src/render/sprites.cpp new file mode 100644 index 0000000..aa7daab --- /dev/null +++ b/src/render/sprites.cpp @@ -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)); +} diff --git a/src/render/sprites.h b/src/render/sprites.h new file mode 100644 index 0000000..142ac00 --- /dev/null +++ b/src/render/sprites.h @@ -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); diff --git a/src/render/view.cpp b/src/render/view.cpp new file mode 100644 index 0000000..1131a49 --- /dev/null +++ b/src/render/view.cpp @@ -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); +} diff --git a/src/render/view.h b/src/render/view.h new file mode 100644 index 0000000..d0241d9 --- /dev/null +++ b/src/render/view.h @@ -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); diff --git a/src/sim/agent.cpp b/src/sim/agent.cpp new file mode 100644 index 0000000..4e978dd --- /dev/null +++ b/src/sim/agent.cpp @@ -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& 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(e, item); + return e; +} + +void SyncLoadout(ecs::World& world, Agent& a) +{ + a.bodyRadius = g_tune.bodyRadius; + + const WeaponItem* item = world.Get(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; +} diff --git a/src/sim/agent.h b/src/sim/agent.h new file mode 100644 index 0000000..01f3d71 --- /dev/null +++ b/src/sim/agent.h @@ -0,0 +1,119 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Агент: состояние + слои движения (спек 6.1, 6.7). +// Никакой изометрии, никакого рендера. +// +// Agent — компонент ECS. Крупный: правила боя читают состояние бойца целиком +// и в одном порядке, дробить его на Position+Velocity+Body значило бы разложить +// один связный алгоритм по пяти пулам без единой строчки выигрыша +// (см. docs/12-ecs.md). А вот ОРУЖИЕ вынесено в отдельную сущность: у него +// действительно своя жизнь, свои статы и своя подменяемость. +// ----------------------------------------------------------------------------- +#include + +#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& 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); diff --git a/src/sim/bullet.cpp b/src/sim/bullet.cpp new file mode 100644 index 0000000..4f746a5 --- /dev/null +++ b/src/sim/bullet.cpp @@ -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& bullets = world.All(); + 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(b.self, b); +} + +void UpdateBullets(ecs::World& world, const Tilemap& map, + Agent* agents, int agentCount, + std::vector& targets, float dt, Metrics& metrics, + CombatEventLog* events) +{ + std::vector& bullets = world.All(); + + // Убирать сущности прямо в цикле нельзя: удаление из пула переставляет + // хвост на освободившееся место. Поэтому сначала шаг, потом похороны. + 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]); +} diff --git a/src/sim/bullet.h b/src/sim/bullet.h new file mode 100644 index 0000000..83bf119 --- /dev/null +++ b/src/sim/bullet.h @@ -0,0 +1,54 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Пули — настоящие снаряды (спек 6.9), не хитскан. +// Коллизия с тайлами и со ВСЕМИ капсулами, включая союзников: friendlyFire +// физически возможен, иначе прототип ничего не доказывает. +// +// Bullet — компонент ECS: у каждой пули своя сущность, которая умирает вместе +// с ней. Свободный список больше не нужен — его роль играет пул. +// ----------------------------------------------------------------------------- +#include + +#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& targets, float dt, Metrics& metrics, + CombatEventLog* events); diff --git a/src/sim/events.h b/src/sim/events.h new file mode 100644 index 0000000..30be39b --- /dev/null +++ b/src/sim/events.h @@ -0,0 +1,55 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Единственный канал «симуляция -> визуальные эффекты». +// +// Почему кольцевой журнал, а не флаги вроде Agent::fired: за один кадр рендера +// проходит ДО MAX_STEPS_PER_FRAME шагов симуляции. Флаг, выставленный на шаге 1 +// и затёртый на шаге 2, эффект не увидит. Журнал переживает все шаги кадра. +// +// Симуляция только ПИШЕТ сюда. Ни одно правило из спека 6 журнал не читает, +// поэтому на детерминизм и на критерии приёмки он не влияет. В headless-прогонах +// указатель на журнал равен nullptr. +// ----------------------------------------------------------------------------- +#include + +#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; + } +}; diff --git a/src/sim/loadout.h b/src/sim/loadout.h new file mode 100644 index 0000000..a401f75 --- /dev/null +++ b/src/sim/loadout.h @@ -0,0 +1,177 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Снаряжение бойца: РАБОЧЕЕ состояние оружия. +// +// Разделение простое и важное: +// * ЧТО такое ствол (дальность, урон, разброс, магазин, вид вспышки) — +// это FirearmDef на сущности-оружии, sim/weapon_defs.h; +// * В КАКОМ ОН СЕЙЧАС СОСТОЯНИИ (откат, патроны, перезарядка, увод ствола) — +// это Firearm здесь, и живёт оно на бойце. +// Подменили сущность оружия — поменялись все статы разом, состояние обнулилось, +// и ни одно правило боя об этом не узнало. +// +// Правила боя от класса НЕ зависят. И стрелок, и боец ближнего боя обязаны +// проверить своих перед ударом; меняется только геометрия проверки — +// конус «дуло -> цель» против сектора замаха (docs/11-melee.md). +// ----------------------------------------------------------------------------- +#include + +#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)); + } +}; diff --git a/src/sim/squad.cpp b/src/sim/squad.cpp new file mode 100644 index 0000000..c872350 --- /dev/null +++ b/src/sim/squad.cpp @@ -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(ids[i], Agent{}); + } + agents.p = world.All().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& 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); + } +} diff --git a/src/sim/squad.h b/src/sim/squad.h new file mode 100644 index 0000000..145a84c --- /dev/null +++ b/src/sim/squad.h @@ -0,0 +1,93 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Отряд: якорь, строй, состояния (спек 6.2), сборка слоёв движения агентов. +// +// Якорь («внутренний курсор») — не телепорт и не переключатель. У него своя +// скорость, свой разгон и упругий поводок до центра масс отряда. Чем сильнее +// игрок тянет курсор от отряда, тем выше НАТЯЖЕНИЕ (urgency): отряд ускоряется, +// строй растягивается вдоль движения, а на самом верху шкалы бойцы просто +// бегут и не стреляют вовсе. Подробности — docs/05-squad-ai.md. +// ----------------------------------------------------------------------------- +#include + +#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& targets, float dt); +}; diff --git a/src/sim/target.cpp b/src/sim/target.cpp new file mode 100644 index 0000000..0eee752 --- /dev/null +++ b/src/sim/target.cpp @@ -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(t.self, t); + } +} + +void KnockTarget(Target& t, Vec2 dir, float speed) +{ + t.vel += dir * (speed * t.KnockScale()); +} + +void UpdateTargets(std::vector& 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& targets) +{ + int n = 0; + for (const Target& t : targets) + if (t.alive) ++n; + return n; +} diff --git a/src/sim/target.h b/src/sim/target.h new file mode 100644 index 0000000..7b14539 --- /dev/null +++ b/src/sim/target.h @@ -0,0 +1,52 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Мишени: статичные, не атакуют, респавнятся на том же месте. +// 12 штук кучками (5 / 3 / 3 / 1) — расстановка захардкожена. +// +// Target — компонент ECS: сущности мишеней создаются в SpawnTargets, а вектор, +// по которому ходят правила, — это плотный массив пула (см. ecs/ecs.h). +// ----------------------------------------------------------------------------- +#include + +#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& targets, float dt); + +// Число живых мишеней (знаменатель в оценке покрытия, спек 6.5). +int CountAliveTargets(const std::vector& targets); diff --git a/src/sim/weapon_defs.h b/src/sim/weapon_defs.h new file mode 100644 index 0000000..b731b09 --- /dev/null +++ b/src/sim/weapon_defs.h @@ -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 + +#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; +} diff --git a/src/tuning.h b/src/tuning.h new file mode 100644 index 0000000..f534a73 --- /dev/null +++ b/src/tuning.h @@ -0,0 +1,459 @@ +#pragma once +#include +// ----------------------------------------------------------------------------- +// Единственное место, где живут числа прототипа. +// В коде не должно быть магических констант — всё берётся отсюда. +// +// * блок 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])); diff --git a/src/world/tilemap.cpp b/src/world/tilemap.cpp new file mode 100644 index 0000000..d7c7f65 --- /dev/null +++ b/src/world/tilemap.cpp @@ -0,0 +1,205 @@ +#include "world/tilemap.h" + +#include + +namespace +{ + +constexpr float INF_F = std::numeric_limits::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; +} diff --git a/src/world/tilemap.h b/src/world/tilemap.h new file mode 100644 index 0000000..a7fe0c9 --- /dev/null +++ b/src/world/tilemap.h @@ -0,0 +1,42 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Карта тайлов: EMPTY / WALL. Декартов мир, тайл = 1.0 юнита. +// Здесь же DDA-рейкаст (Amanatides & Woo) и circle-vs-AABB разрешение коллизий. +// Об изометрии этот модуль не знает. +// ----------------------------------------------------------------------------- +#include + +#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; +};