Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (raylib только окно, ввод и финальный блит). Ядро (спек, раздел 6): - LaneClear: проверка линии огня по своим, стенам и дальности; - решатель огневых позиций с гистерезисом и коммитом; - резервирование линий огня и расступание из чужих секторов; - пули-снаряды, дружественный урон физически возможен. Хоррор-слой (render-only): свет и туман войны с памятью карты, светящиеся трассеры, кровь, виньетка, зерно, напряжение. Ближний бой: сектор удара, три фазы, связка из трёх ударов, своя дисциплина «не бить сквозь своего». Проверка: squad_proto.exe --accept прогоняет критерии приёмки раздела 11 плюс блок M7 по ближнему бою — все PASS, FRIENDLY_HITS = 0 за 3 минуты боя. Сборка: cmake -S . -B build && cmake --build build --config Release raylib 6.0 подтягивается через FetchContent. Документация — CLAUDE.md как оглавление, содержание в docs/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
7880f275bd
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
Makefile linguist-generated=true
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# --- сборка ------------------------------------------------------------------
|
||||
# build/ содержит и объектники, и выкачанный исходник raylib (~200 МБ).
|
||||
# Всё воспроизводится одной командой: cmake -S . -B build
|
||||
build/
|
||||
out/
|
||||
cmake-build-*/
|
||||
|
||||
*.exe
|
||||
*.pdb
|
||||
*.ilk
|
||||
*.obj
|
||||
*.dsym
|
||||
main
|
||||
|
||||
# --- артефакты dev-режимов ---------------------------------------------------
|
||||
# squad_proto.exe --shot кладёт скриншот в рабочий каталог, то есть в корень.
|
||||
# Это отладочные снимки; те, что нужны документации, лежат в docs/.
|
||||
/*.png
|
||||
|
||||
# --- среда -------------------------------------------------------------------
|
||||
.vs/
|
||||
.cache/
|
||||
compile_commands.json
|
||||
CMakeUserPresets.json
|
||||
0
.vscode/.gitkeep
vendored
Normal file
0
.vscode/.gitkeep
vendored
Normal file
62
.vscode/c_cpp_properties.json
vendored
Normal file
62
.vscode/c_cpp_properties.json
vendored
Normal file
@ -0,0 +1,62 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Win32",
|
||||
"includePath": [
|
||||
"C:/raylib/raylib/src/**",
|
||||
"${workspaceFolder}/**"
|
||||
],
|
||||
"defines": [
|
||||
"_DEBUG",
|
||||
"UNICODE",
|
||||
"_UNICODE",
|
||||
"GRAPHICS_API_OPENGL_33",
|
||||
"PLATFORM_DESKTOP"
|
||||
],
|
||||
"compilerPath": "C:/raylib/w64devkit/bin/gcc.exe",
|
||||
"cStandard": "c99",
|
||||
"cppStandard": "c++14",
|
||||
"intelliSenseMode": "gcc-x64",
|
||||
"configurationProvider": "ms-vscode.makefile-tools"
|
||||
},
|
||||
{
|
||||
"name": "Mac",
|
||||
"includePath": [
|
||||
"${workspaceFolder}/**",
|
||||
"/opt/homebrew/include"
|
||||
],
|
||||
"defines": [
|
||||
"_DEBUG",
|
||||
"UNICODE",
|
||||
"_UNICODE",
|
||||
"GRAPHICS_API_OPENGL_33",
|
||||
"PLATFORM_DESKTOP"
|
||||
],
|
||||
"macFrameworkPath": [
|
||||
"/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.13.sdk/System/Library/Frameworks"
|
||||
],
|
||||
"compilerPath": "/usr/bin/clang",
|
||||
"cStandard": "c11",
|
||||
"cppStandard": "c++14",
|
||||
"intelliSenseMode": "clang-x64"
|
||||
},
|
||||
{
|
||||
"name": "Linux",
|
||||
"includePath": [
|
||||
"/home/linuxbrew/.linuxbrew/include",
|
||||
"${workspaceFolder}/**"
|
||||
],
|
||||
"defines": [
|
||||
"_DEBUG",
|
||||
"UNICODE",
|
||||
"_UNICODE",
|
||||
"GRAPHICS_API_OPENGL_33",
|
||||
"PLATFORM_DESKTOP"
|
||||
],
|
||||
"cStandard": "c11",
|
||||
"cppStandard": "c++14",
|
||||
"intelliSenseMode": "gcc-x64"
|
||||
}
|
||||
],
|
||||
"version": 4
|
||||
}
|
||||
40
.vscode/launch.json
vendored
Normal file
40
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run (Release)",
|
||||
"type": "cppvsdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/build/bin/squad_proto.exe",
|
||||
"args": [],
|
||||
"stopAtEntry": false,
|
||||
"cwd": "${workspaceFolder}",
|
||||
"environment": [],
|
||||
"console": "integratedTerminal",
|
||||
"preLaunchTask": "build release"
|
||||
},
|
||||
{
|
||||
"name": "Debug",
|
||||
"type": "cppvsdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/build/bin/squad_proto.exe",
|
||||
"args": [],
|
||||
"stopAtEntry": false,
|
||||
"cwd": "${workspaceFolder}",
|
||||
"environment": [],
|
||||
"console": "integratedTerminal",
|
||||
"preLaunchTask": "build debug"
|
||||
},
|
||||
{
|
||||
"name": "Acceptance (--accept)",
|
||||
"type": "cppvsdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/build/bin/squad_proto.exe",
|
||||
"args": ["--accept"],
|
||||
"stopAtEntry": false,
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"preLaunchTask": "build release"
|
||||
}
|
||||
]
|
||||
}
|
||||
11
.vscode/settings.json
vendored
Normal file
11
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.svn": true,
|
||||
"**/.hg": true,
|
||||
"**/CVS": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/*.o": true,
|
||||
"**/*.exe": true,
|
||||
}
|
||||
}
|
||||
48
.vscode/tasks.json
vendored
Normal file
48
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "cmake configure",
|
||||
"type": "shell",
|
||||
"command": "cmake",
|
||||
"args": ["-S", ".", "-B", "build"],
|
||||
"problemMatcher": [],
|
||||
"detail": "Разовая конфигурация; raylib 6.0 подтягивается через FetchContent"
|
||||
},
|
||||
{
|
||||
"label": "build release",
|
||||
"type": "shell",
|
||||
"command": "cmake",
|
||||
"args": ["--build", "build", "--config", "Release", "--parallel"],
|
||||
"group": { "kind": "build", "isDefault": true },
|
||||
"problemMatcher": ["$msCompile"],
|
||||
"detail": "Основная сборка -> build/bin/squad_proto.exe"
|
||||
},
|
||||
{
|
||||
"label": "build debug",
|
||||
"type": "shell",
|
||||
"command": "cmake",
|
||||
"args": ["--build", "build", "--config", "Debug", "--parallel"],
|
||||
"group": "build",
|
||||
"problemMatcher": ["$msCompile"]
|
||||
},
|
||||
{
|
||||
"label": "accept",
|
||||
"type": "shell",
|
||||
"command": "${workspaceFolder}/build/bin/squad_proto.exe",
|
||||
"args": ["--accept"],
|
||||
"dependsOn": "build release",
|
||||
"problemMatcher": [],
|
||||
"detail": "Прогнать критерии приёмки из спека (раздел 11)"
|
||||
},
|
||||
{
|
||||
"label": "headless 180s",
|
||||
"type": "shell",
|
||||
"command": "${workspaceFolder}/build/bin/squad_proto.exe",
|
||||
"args": ["--headless", "180"],
|
||||
"dependsOn": "build release",
|
||||
"problemMatcher": [],
|
||||
"detail": "Прогон без окна с метриками"
|
||||
}
|
||||
]
|
||||
}
|
||||
32
CLAUDE.md
Normal file
32
CLAUDE.md
Normal file
@ -0,0 +1,32 @@
|
||||
# CLAUDE.md — оглавление
|
||||
|
||||
Этот файл — **только навигация**. Содержание живёт в `docs/`, по одному файлу на тему.
|
||||
Читай нужный раздел, а не весь проект.
|
||||
|
||||
## Документация
|
||||
|
||||
| Файл | О чём | Когда открывать |
|
||||
|---|---|---|
|
||||
| [docs/00-overview.md](docs/00-overview.md) | что это за прототип, границы задачи | первое знакомство |
|
||||
| [docs/01-build-and-run.md](docs/01-build-and-run.md) | сборка, запуск, управление, dev-режимы CLI | собрать и потыкать |
|
||||
| [docs/02-architecture.md](docs/02-architecture.md) | карта модулей, порядок шага симуляции, правила зависимостей | перед любой правкой кода |
|
||||
| [docs/03-rendering.md](docs/03-rendering.md) | софтверный фреймбуфер, изометрия, сортировка, спрайты | рендер, камера, спрайты |
|
||||
| [docs/04-world.md](docs/04-world.md) | тайлмап, планировка уровня, DDA-рейкаст, коллизии, мишени | карта, LOS, коллизии |
|
||||
| [docs/05-squad-ai.md](docs/05-squad-ai.md) | **ядро**: LaneClear, решатель огневых позиций, резерв линий, стрельба | поведение отряда |
|
||||
| [docs/06-debug-and-tuning.md](docs/06-debug-and-tuning.md) | оверлей 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&` — эффекты физически не могут влиять на симуляцию.
|
||||
53
CMakeLists.txt
Normal file
53
CMakeLists.txt
Normal file
@ -0,0 +1,53 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(squad_proto CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# raylib: системная, иначе выкачиваем 6.0 через FetchContent
|
||||
# ---------------------------------------------------------------------------
|
||||
find_package(raylib 6 QUIET)
|
||||
|
||||
if(NOT raylib_FOUND)
|
||||
include(FetchContent)
|
||||
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_GAMES OFF CACHE BOOL "" FORCE)
|
||||
set(CUSTOMIZE_BUILD OFF CACHE BOOL "" FORCE)
|
||||
FetchContent_Declare(
|
||||
raylib
|
||||
URL https://github.com/raysan5/raylib/archive/refs/tags/6.0.tar.gz
|
||||
DOWNLOAD_EXTRACT_TIMESTAMP TRUE # CMP0135: иначе CMake ругается на таймстемпы
|
||||
)
|
||||
FetchContent_MakeAvailable(raylib)
|
||||
endif()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Исходники
|
||||
# ---------------------------------------------------------------------------
|
||||
file(GLOB_RECURSE SQUAD_SOURCES CONFIGURE_DEPENDS
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
|
||||
|
||||
add_executable(squad_proto ${SQUAD_SOURCES})
|
||||
|
||||
target_include_directories(squad_proto PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||
target_link_libraries(squad_proto PRIVATE raylib)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(squad_proto PRIVATE /W4 /utf-8 /permissive-)
|
||||
else()
|
||||
target_compile_options(squad_proto PRIVATE -Wall -Wextra)
|
||||
endif()
|
||||
|
||||
if(WIN32)
|
||||
target_link_libraries(squad_proto PRIVATE winmm)
|
||||
endif()
|
||||
|
||||
# Единый каталог для бинарника при любом генераторе (VS/Ninja/Makefiles)
|
||||
set_target_properties(squad_proto PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
||||
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin"
|
||||
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin"
|
||||
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin"
|
||||
RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}/bin")
|
||||
421
Makefile
generated
Normal file
421
Makefile
generated
Normal file
@ -0,0 +1,421 @@
|
||||
#**************************************************************************************************
|
||||
#
|
||||
# raylib makefile for Desktop platforms, Raspberry Pi, Android and HTML5
|
||||
#
|
||||
# Copyright (c) 2013-2019 Ramon Santamaria (@raysan5)
|
||||
#
|
||||
# This software is provided "as-is", without any express or implied warranty. In no event
|
||||
# will the authors be held liable for any damages arising from the use of this software.
|
||||
#
|
||||
# Permission is granted to anyone to use this software for any purpose, including commercial
|
||||
# applications, and to alter it and redistribute it freely, subject to the following restrictions:
|
||||
#
|
||||
# 1. The origin of this software must not be misrepresented; you must not claim that you
|
||||
# wrote the original software. If you use this software in a product, an acknowledgment
|
||||
# in the product documentation would be appreciated but is not required.
|
||||
#
|
||||
# 2. Altered source versions must be plainly marked as such, and must not be misrepresented
|
||||
# as being the original software.
|
||||
#
|
||||
# 3. This notice may not be removed or altered from any source distribution.
|
||||
#
|
||||
#**************************************************************************************************
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
# Define required raylib variables
|
||||
PROJECT_NAME ?= game
|
||||
RAYLIB_VERSION ?= 5.0.0
|
||||
RAYLIB_PATH ?= ..\..
|
||||
|
||||
# Define compiler path on Windows
|
||||
COMPILER_PATH ?= C:/raylib/w64devkit/bin
|
||||
|
||||
# Define default options
|
||||
# One of PLATFORM_DESKTOP, PLATFORM_RPI, PLATFORM_ANDROID, PLATFORM_WEB
|
||||
PLATFORM ?= PLATFORM_DESKTOP
|
||||
|
||||
# Locations of your newly installed library and associated headers. See ../src/Makefile
|
||||
# On Linux, if you have installed raylib but cannot compile the examples, check that
|
||||
# the *_INSTALL_PATH values here are the same as those in src/Makefile or point to known locations.
|
||||
# To enable system-wide compile-time and runtime linking to libraylib.so, run ../src/$ sudo make install RAYLIB_LIBTYPE_SHARED.
|
||||
# To enable compile-time linking to a special version of libraylib.so, change these variables here.
|
||||
# To enable runtime linking to a special version of libraylib.so, see EXAMPLE_RUNTIME_PATH below.
|
||||
# If there is a libraylib in both EXAMPLE_RUNTIME_PATH and RAYLIB_INSTALL_PATH, at runtime,
|
||||
# the library at EXAMPLE_RUNTIME_PATH, if present, will take precedence over the one at RAYLIB_INSTALL_PATH.
|
||||
# RAYLIB_INSTALL_PATH should be the desired full path to libraylib. No relative paths.
|
||||
DESTDIR ?= /usr/local
|
||||
RAYLIB_INSTALL_PATH ?= $(DESTDIR)/lib
|
||||
# RAYLIB_H_INSTALL_PATH locates the installed raylib header and associated source files.
|
||||
RAYLIB_H_INSTALL_PATH ?= $(DESTDIR)/include
|
||||
|
||||
# Library type used for raylib: STATIC (.a) or SHARED (.so/.dll)
|
||||
RAYLIB_LIBTYPE ?= STATIC
|
||||
|
||||
# Build mode for project: DEBUG or RELEASE
|
||||
BUILD_MODE ?= RELEASE
|
||||
|
||||
# Use external GLFW library instead of rglfw module
|
||||
# TODO: Review usage on Linux. Target version of choice. Switch on -lglfw or -lglfw3
|
||||
USE_EXTERNAL_GLFW ?= FALSE
|
||||
|
||||
# Use Wayland display server protocol on Linux desktop
|
||||
# by default it uses X11 windowing system
|
||||
USE_WAYLAND_DISPLAY ?= FALSE
|
||||
|
||||
# Determine PLATFORM_OS in case PLATFORM_DESKTOP selected
|
||||
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
|
||||
# No uname.exe on MinGW!, but OS=Windows_NT on Windows!
|
||||
# ifeq ($(UNAME),Msys) -> Windows
|
||||
ifeq ($(OS),Windows_NT)
|
||||
PLATFORM_OS=WINDOWS
|
||||
export PATH := $(COMPILER_PATH):$(PATH)
|
||||
else
|
||||
UNAMEOS=$(shell uname)
|
||||
ifeq ($(UNAMEOS),Linux)
|
||||
PLATFORM_OS=LINUX
|
||||
endif
|
||||
ifeq ($(UNAMEOS),FreeBSD)
|
||||
PLATFORM_OS=BSD
|
||||
endif
|
||||
ifeq ($(UNAMEOS),OpenBSD)
|
||||
PLATFORM_OS=BSD
|
||||
endif
|
||||
ifeq ($(UNAMEOS),NetBSD)
|
||||
PLATFORM_OS=BSD
|
||||
endif
|
||||
ifeq ($(UNAMEOS),DragonFly)
|
||||
PLATFORM_OS=BSD
|
||||
endif
|
||||
ifeq ($(UNAMEOS),Darwin)
|
||||
PLATFORM_OS=OSX
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
ifeq ($(PLATFORM),PLATFORM_RPI)
|
||||
UNAMEOS=$(shell uname)
|
||||
ifeq ($(UNAMEOS),Linux)
|
||||
PLATFORM_OS=LINUX
|
||||
endif
|
||||
endif
|
||||
|
||||
# RAYLIB_PATH adjustment for different platforms.
|
||||
# If using GNU make, we can get the full path to the top of the tree. Windows? BSD?
|
||||
# Required for ldconfig or other tools that do not perform path expansion.
|
||||
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
|
||||
ifeq ($(PLATFORM_OS),LINUX)
|
||||
RAYLIB_PREFIX ?= ..
|
||||
RAYLIB_PATH = $(realpath $(RAYLIB_PREFIX))
|
||||
endif
|
||||
endif
|
||||
# Default path for raylib on Raspberry Pi, if installed in different path, update it!
|
||||
# This is not currently used by src/Makefile. Not sure of its origin or usage. Refer to wiki.
|
||||
# TODO: update install: target in src/Makefile for RPI, consider relation to LINUX.
|
||||
ifeq ($(PLATFORM),PLATFORM_RPI)
|
||||
RAYLIB_PATH ?= /home/pi/raylib
|
||||
endif
|
||||
|
||||
ifeq ($(PLATFORM),PLATFORM_WEB)
|
||||
# Emscripten required variables
|
||||
EMSDK_PATH ?= C:/emsdk
|
||||
EMSCRIPTEN_VERSION ?= 1.38.31
|
||||
CLANG_VERSION = e$(EMSCRIPTEN_VERSION)_64bit
|
||||
PYTHON_VERSION = 2.7.13.1_64bit\python-2.7.13.amd64
|
||||
NODE_VERSION = 8.9.1_64bit
|
||||
export PATH = $(EMSDK_PATH);$(EMSDK_PATH)\clang\$(CLANG_VERSION);$(EMSDK_PATH)\node\$(NODE_VERSION)\bin;$(EMSDK_PATH)\python\$(PYTHON_VERSION);$(EMSDK_PATH)\emscripten\$(EMSCRIPTEN_VERSION);C:\raylib\MinGW\bin:$$(PATH)
|
||||
EMSCRIPTEN = $(EMSDK_PATH)\emscripten\$(EMSCRIPTEN_VERSION)
|
||||
endif
|
||||
|
||||
# Define raylib release directory for compiled library.
|
||||
# RAYLIB_RELEASE_PATH points to provided binaries or your freshly built version
|
||||
RAYLIB_RELEASE_PATH ?= $(RAYLIB_PATH)/src
|
||||
|
||||
# EXAMPLE_RUNTIME_PATH embeds a custom runtime location of libraylib.so or other desired libraries
|
||||
# into each example binary compiled with RAYLIB_LIBTYPE=SHARED. It defaults to RAYLIB_RELEASE_PATH
|
||||
# so that these examples link at runtime with your version of libraylib.so in ../release/libs/linux
|
||||
# without formal installation from ../src/Makefile. It aids portability and is useful if you have
|
||||
# multiple versions of raylib, have raylib installed to a non-standard location, or want to
|
||||
# bundle libraylib.so with your game. Change it to your liking.
|
||||
# NOTE: If, at runtime, there is a libraylib.so at both EXAMPLE_RUNTIME_PATH and RAYLIB_INSTALL_PATH,
|
||||
# The library at EXAMPLE_RUNTIME_PATH, if present, will take precedence over RAYLIB_INSTALL_PATH,
|
||||
# Implemented for LINUX below with CFLAGS += -Wl,-rpath,$(EXAMPLE_RUNTIME_PATH)
|
||||
# To see the result, run readelf -d core/core_basic_window; looking at the RPATH or RUNPATH attribute.
|
||||
# To see which libraries a built example is linking to, ldd core/core_basic_window;
|
||||
# Look for libraylib.so.1 => $(RAYLIB_INSTALL_PATH)/libraylib.so.1 or similar listing.
|
||||
EXAMPLE_RUNTIME_PATH ?= $(RAYLIB_RELEASE_PATH)
|
||||
|
||||
# Define default C compiler: gcc
|
||||
# NOTE: define g++ compiler if using C++
|
||||
CC = g++
|
||||
|
||||
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
|
||||
ifeq ($(PLATFORM_OS),OSX)
|
||||
# OSX default compiler
|
||||
CC = clang++
|
||||
endif
|
||||
ifeq ($(PLATFORM_OS),BSD)
|
||||
# FreeBSD, OpenBSD, NetBSD, DragonFly default compiler
|
||||
CC = clang
|
||||
endif
|
||||
endif
|
||||
ifeq ($(PLATFORM),PLATFORM_RPI)
|
||||
ifeq ($(USE_RPI_CROSS_COMPILER),TRUE)
|
||||
# Define RPI cross-compiler
|
||||
#CC = armv6j-hardfloat-linux-gnueabi-gcc
|
||||
CC = $(RPI_TOOLCHAIN)/bin/arm-linux-gnueabihf-gcc
|
||||
endif
|
||||
endif
|
||||
ifeq ($(PLATFORM),PLATFORM_WEB)
|
||||
# HTML5 emscripten compiler
|
||||
# WARNING: To compile to HTML5, code must be redesigned
|
||||
# to use emscripten.h and emscripten_set_main_loop()
|
||||
CC = emcc
|
||||
endif
|
||||
|
||||
# Define default make program: Mingw32-make
|
||||
MAKE = mingw32-make
|
||||
|
||||
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
|
||||
ifeq ($(PLATFORM_OS),LINUX)
|
||||
MAKE = make
|
||||
endif
|
||||
ifeq ($(PLATFORM_OS),OSX)
|
||||
MAKE = make
|
||||
endif
|
||||
endif
|
||||
|
||||
# Define compiler flags:
|
||||
# -O0 defines optimization level (no optimization, better for debugging)
|
||||
# -O1 defines optimization level
|
||||
# -g include debug information on compilation
|
||||
# -s strip unnecessary data from build -> do not use in debug builds
|
||||
# -Wall turns on most, but not all, compiler warnings
|
||||
# -std=c99 defines C language mode (standard C from 1999 revision)
|
||||
# -std=gnu99 defines C language mode (GNU C from 1999 revision)
|
||||
# -Wno-missing-braces ignore invalid warning (GCC bug 53119)
|
||||
# -D_DEFAULT_SOURCE use with -std=c99 on Linux and PLATFORM_WEB, required for timespec
|
||||
CFLAGS += -Wall -std=c++14 -D_DEFAULT_SOURCE -Wno-missing-braces
|
||||
|
||||
ifeq ($(BUILD_MODE),DEBUG)
|
||||
CFLAGS += -g -O0
|
||||
else
|
||||
CFLAGS += -s -O1
|
||||
endif
|
||||
|
||||
# Additional flags for compiler (if desired)
|
||||
#CFLAGS += -Wextra -Wmissing-prototypes -Wstrict-prototypes
|
||||
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
|
||||
ifeq ($(PLATFORM_OS),WINDOWS)
|
||||
# resource file contains windows executable icon and properties
|
||||
# -Wl,--subsystem,windows hides the console window
|
||||
CFLAGS += $(RAYLIB_PATH)/src/raylib.rc.data
|
||||
endif
|
||||
ifeq ($(PLATFORM_OS),LINUX)
|
||||
ifeq ($(RAYLIB_LIBTYPE),STATIC)
|
||||
CFLAGS += -D_DEFAULT_SOURCE
|
||||
endif
|
||||
ifeq ($(RAYLIB_LIBTYPE),SHARED)
|
||||
# Explicitly enable runtime link to libraylib.so
|
||||
CFLAGS += -Wl,-rpath,$(EXAMPLE_RUNTIME_PATH)
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
ifeq ($(PLATFORM),PLATFORM_RPI)
|
||||
CFLAGS += -std=gnu99
|
||||
endif
|
||||
ifeq ($(PLATFORM),PLATFORM_WEB)
|
||||
# -Os # size optimization
|
||||
# -O2 # optimization level 2, if used, also set --memory-init-file 0
|
||||
# -s USE_GLFW=3 # Use glfw3 library (context/input management)
|
||||
# -s ALLOW_MEMORY_GROWTH=1 # to allow memory resizing -> WARNING: Audio buffers could FAIL!
|
||||
# -s TOTAL_MEMORY=16777216 # to specify heap memory size (default = 16MB)
|
||||
# -s USE_PTHREADS=1 # multithreading support
|
||||
# -s WASM=0 # disable Web Assembly, emitted by default
|
||||
# -s EMTERPRETIFY=1 # enable emscripten code interpreter (very slow)
|
||||
# -s EMTERPRETIFY_ASYNC=1 # support synchronous loops by emterpreter
|
||||
# -s FORCE_FILESYSTEM=1 # force filesystem to load/save files data
|
||||
# -s ASSERTIONS=1 # enable runtime checks for common memory allocation errors (-O1 and above turn it off)
|
||||
# --profiling # include information for code profiling
|
||||
# --memory-init-file 0 # to avoid an external memory initialization code file (.mem)
|
||||
# --preload-file resources # specify a resources folder for data compilation
|
||||
CFLAGS += -Os -s USE_GLFW=3 -s TOTAL_MEMORY=16777216 --preload-file resources
|
||||
ifeq ($(BUILD_MODE), DEBUG)
|
||||
CFLAGS += -s ASSERTIONS=1 --profiling
|
||||
endif
|
||||
|
||||
# Define a custom shell .html and output extension
|
||||
CFLAGS += --shell-file $(RAYLIB_PATH)/src/shell.html
|
||||
EXT = .html
|
||||
endif
|
||||
|
||||
# Define include paths for required headers
|
||||
# NOTE: Several external required libraries (stb and others)
|
||||
INCLUDE_PATHS = -I. -I$(RAYLIB_PATH)/src -I$(RAYLIB_PATH)/src/external
|
||||
ifneq ($(wildcard /opt/homebrew/include/.*),)
|
||||
INCLUDE_PATHS += -I/opt/homebrew/include
|
||||
endif
|
||||
|
||||
# Define additional directories containing required header files
|
||||
ifeq ($(PLATFORM),PLATFORM_RPI)
|
||||
# RPI required libraries
|
||||
INCLUDE_PATHS += -I/opt/vc/include
|
||||
INCLUDE_PATHS += -I/opt/vc/include/interface/vmcs_host/linux
|
||||
INCLUDE_PATHS += -I/opt/vc/include/interface/vcos/pthreads
|
||||
endif
|
||||
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
|
||||
ifeq ($(PLATFORM_OS),BSD)
|
||||
# Consider -L$(RAYLIB_H_INSTALL_PATH)
|
||||
INCLUDE_PATHS += -I/usr/local/include
|
||||
endif
|
||||
ifeq ($(PLATFORM_OS),LINUX)
|
||||
# Reset everything.
|
||||
# Precedence: immediately local, installed version, raysan5 provided libs -I$(RAYLIB_H_INSTALL_PATH) -I$(RAYLIB_PATH)/release/include
|
||||
INCLUDE_PATHS = -I$(RAYLIB_H_INSTALL_PATH) -isystem. -isystem$(RAYLIB_PATH)/src -isystem$(RAYLIB_PATH)/release/include -isystem$(RAYLIB_PATH)/src/external
|
||||
endif
|
||||
endif
|
||||
|
||||
# Define library paths containing required libs.
|
||||
LDFLAGS = -L.
|
||||
|
||||
ifneq ($(wildcard $(RAYLIB_RELEASE_PATH)/.*),)
|
||||
LDFLAGS += -L$(RAYLIB_RELEASE_PATH)
|
||||
endif
|
||||
ifneq ($(wildcard $(RAYLIB_PATH)/src/.*),)
|
||||
LDFLAGS += -L$(RAYLIB_PATH)/src
|
||||
endif
|
||||
ifneq ($(wildcard /opt/homebrew/lib/.*),)
|
||||
LDFLAGS += -L/opt/homebrew/lib
|
||||
endif
|
||||
|
||||
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
|
||||
ifeq ($(PLATFORM_OS),BSD)
|
||||
# Consider -L$(RAYLIB_INSTALL_PATH)
|
||||
LDFLAGS += -L. -Lsrc -L/usr/local/lib
|
||||
endif
|
||||
ifeq ($(PLATFORM_OS),LINUX)
|
||||
# Reset everything.
|
||||
# Precedence: immediately local, installed version, raysan5 provided libs
|
||||
LDFLAGS = -L. -L$(RAYLIB_INSTALL_PATH) -L$(RAYLIB_RELEASE_PATH)
|
||||
endif
|
||||
endif
|
||||
|
||||
ifeq ($(PLATFORM),PLATFORM_RPI)
|
||||
LDFLAGS += -L/opt/vc/lib
|
||||
endif
|
||||
|
||||
# Define any libraries required on linking
|
||||
# if you want to link libraries (libname.so or libname.a), use the -lname
|
||||
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
|
||||
ifeq ($(PLATFORM_OS),WINDOWS)
|
||||
# Libraries for Windows desktop compilation
|
||||
# NOTE: WinMM library required to set high-res timer resolution
|
||||
LDLIBS = -lraylib -lopengl32 -lgdi32 -lwinmm
|
||||
# Required for physac examples
|
||||
#LDLIBS += -static -lpthread
|
||||
endif
|
||||
ifeq ($(PLATFORM_OS),LINUX)
|
||||
# Libraries for Debian GNU/Linux desktop compiling
|
||||
# NOTE: Required packages: libegl1-mesa-dev
|
||||
LDLIBS = -lraylib -lGL -lm -lpthread -ldl -lrt
|
||||
|
||||
# On X11 requires also below libraries
|
||||
LDLIBS += -lX11
|
||||
# NOTE: It seems additional libraries are not required any more, latest GLFW just dlopen them
|
||||
#LDLIBS += -lXrandr -lXinerama -lXi -lXxf86vm -lXcursor
|
||||
|
||||
# On Wayland windowing system, additional libraries requires
|
||||
ifeq ($(USE_WAYLAND_DISPLAY),TRUE)
|
||||
LDLIBS += -lwayland-client -lwayland-cursor -lwayland-egl -lxkbcommon
|
||||
endif
|
||||
# Explicit link to libc
|
||||
ifeq ($(RAYLIB_LIBTYPE),SHARED)
|
||||
LDLIBS += -lc
|
||||
endif
|
||||
endif
|
||||
ifeq ($(PLATFORM_OS),OSX)
|
||||
# Libraries for OSX 10.9 desktop compiling
|
||||
# NOTE: Required packages: libopenal-dev libegl1-mesa-dev
|
||||
LDLIBS = -lraylib -framework OpenGL -framework OpenAL -framework Cocoa -framework IOKit
|
||||
endif
|
||||
ifeq ($(PLATFORM_OS),BSD)
|
||||
# Libraries for FreeBSD, OpenBSD, NetBSD, DragonFly desktop compiling
|
||||
# NOTE: Required packages: mesa-libs
|
||||
LDLIBS = -lraylib -lGL -lpthread -lm
|
||||
|
||||
# On XWindow requires also below libraries
|
||||
LDLIBS += -lX11 -lXrandr -lXinerama -lXi -lXxf86vm -lXcursor
|
||||
endif
|
||||
ifeq ($(USE_EXTERNAL_GLFW),TRUE)
|
||||
# NOTE: It could require additional packages installed: libglfw3-dev
|
||||
LDLIBS += -lglfw
|
||||
endif
|
||||
endif
|
||||
ifeq ($(PLATFORM),PLATFORM_RPI)
|
||||
# Libraries for Raspberry Pi compiling
|
||||
# NOTE: Required packages: libasound2-dev (ALSA)
|
||||
LDLIBS = -lraylib -lbrcmGLESv2 -lbrcmEGL -lpthread -lrt -lm -lbcm_host -ldl
|
||||
endif
|
||||
ifeq ($(PLATFORM),PLATFORM_WEB)
|
||||
# Libraries for web (HTML5) compiling
|
||||
LDLIBS = $(RAYLIB_RELEASE_PATH)/libraylib.bc
|
||||
endif
|
||||
|
||||
# Define a recursive wildcard function
|
||||
rwildcard=$(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2) $(filter $(subst *,%,$2),$d))
|
||||
|
||||
# Define all source files required
|
||||
SRC_DIR = src
|
||||
OBJ_DIR = obj
|
||||
|
||||
# Define all object files from source files
|
||||
SRC = $(call rwildcard, *.c, *.h)
|
||||
#OBJS = $(SRC:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
|
||||
OBJS ?= main.c
|
||||
|
||||
# For Android platform we call a custom Makefile.Android
|
||||
ifeq ($(PLATFORM),PLATFORM_ANDROID)
|
||||
MAKEFILE_PARAMS = -f Makefile.Android
|
||||
export PROJECT_NAME
|
||||
export SRC_DIR
|
||||
else
|
||||
MAKEFILE_PARAMS = $(PROJECT_NAME)
|
||||
endif
|
||||
|
||||
# Default target entry
|
||||
# NOTE: We call this Makefile target or Makefile.Android target
|
||||
all:
|
||||
$(MAKE) $(MAKEFILE_PARAMS)
|
||||
|
||||
# Project target defined by PROJECT_NAME
|
||||
$(PROJECT_NAME): $(OBJS)
|
||||
$(CC) -o $(PROJECT_NAME)$(EXT) $(OBJS) $(CFLAGS) $(INCLUDE_PATHS) $(LDFLAGS) $(LDLIBS) -D$(PLATFORM)
|
||||
|
||||
# Compile source files
|
||||
# NOTE: This pattern will compile every module defined on $(OBJS)
|
||||
#%.o: %.c
|
||||
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
|
||||
$(CC) -c $< -o $@ $(CFLAGS) $(INCLUDE_PATHS) -D$(PLATFORM)
|
||||
|
||||
# Clean everything
|
||||
clean:
|
||||
ifeq ($(PLATFORM),PLATFORM_DESKTOP)
|
||||
ifeq ($(PLATFORM_OS),WINDOWS)
|
||||
del *.o *.exe /s
|
||||
endif
|
||||
ifeq ($(PLATFORM_OS),LINUX)
|
||||
find -type f -executable | xargs file -i | grep -E 'x-object|x-archive|x-sharedlib|x-executable' | rev | cut -d ':' -f 2- | rev | xargs rm -fv
|
||||
endif
|
||||
ifeq ($(PLATFORM_OS),OSX)
|
||||
find . -type f -perm +ugo+x -delete
|
||||
rm -f *.o
|
||||
endif
|
||||
endif
|
||||
ifeq ($(PLATFORM),PLATFORM_RPI)
|
||||
find . -type f -executable -delete
|
||||
rm -fv *.o
|
||||
endif
|
||||
ifeq ($(PLATFORM),PLATFORM_WEB)
|
||||
del *.o *.html *.js
|
||||
endif
|
||||
@echo Cleaning done
|
||||
|
||||
60
README.md
Normal file
60
README.md
Normal file
@ -0,0 +1,60 @@
|
||||
# Squad prototype — тактический отряд с авто-огнём
|
||||
|
||||
Технический прототип боевого ядра 2D-изометрического sci-fi horror roguelite.
|
||||
Отряд из 5 агентов сам расставляется так, чтобы стрелять и **не задевать своих**.
|
||||
Двое из пяти работают клинком: у ближнего боя то же правило в угловой форме —
|
||||
свой в секторе замаха, значит удара не будет.
|
||||
|
||||

|
||||
|
||||
C++17 · raylib 6.0 · CMake · **софтверный рендер** (вся сцена — ручная запись
|
||||
пикселей в свой фреймбуфер 480×270, апскейл ×3).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
cmake -S . -B build # разово, тянет raylib 6.0
|
||||
cmake --build build --config Release --parallel
|
||||
build/bin/squad_proto.exe
|
||||
```
|
||||
|
||||
В VS Code — просто `F5`.
|
||||
|
||||
`WASD` двигает якорь отряда, `Space` — ручное прицеливание, `1/2/3` — строй,
|
||||
`F1..F4` — оверлей. Стрельба всегда автоматическая. Полный список — в документации.
|
||||
|
||||
## Самопроверка
|
||||
|
||||
```bash
|
||||
build/bin/squad_proto.exe --accept # критерии приёмки, PASS/FAIL
|
||||
build/bin/squad_proto.exe --headless 300 # прогон без окна + метрики
|
||||
```
|
||||
|
||||
Симуляция детерминирована и не зависит от рендера, поэтому критерии проверяются
|
||||
цифрами: `FRIENDLY_HITS = 0` за 3 минуты боя смешанным отрядом, `FIRE UPTIME`
|
||||
100 % в стоячем бою, расхождение веером за 0.18 с, 81 взмах клинком без единого
|
||||
задетого своего, шаг симуляции 2 мкс при бюджете кадра 16.6 мс.
|
||||
|
||||
## Документация
|
||||
|
||||
Оглавление — [CLAUDE.md](CLAUDE.md). Модульная документация в [docs/](docs/):
|
||||
|
||||
- [00-overview](docs/00-overview.md) — что это и чего тут нет
|
||||
- [01-build-and-run](docs/01-build-and-run.md) — сборка, управление, dev-режимы
|
||||
- [02-architecture](docs/02-architecture.md) — модули, порядок шага, правила зависимостей
|
||||
- [03-rendering](docs/03-rendering.md) — фреймбуфер, изометрия, спрайты
|
||||
- [04-world](docs/04-world.md) — карта, рейкаст, коллизии, мишени
|
||||
- [05-squad-ai](docs/05-squad-ai.md) — **ядро**: линии огня, решатель, резервирование
|
||||
- [06-debug-and-tuning](docs/06-debug-and-tuning.md) — оверлей, метрики, тюнинг
|
||||
- [07-milestones](docs/07-milestones.md) — журнал вех
|
||||
- [08-decisions](docs/08-decisions.md) — отклонения от спека и почему
|
||||
- [09-acceptance](docs/09-acceptance.md) — критерии приёмки и цифры
|
||||
- [10-atmosphere](docs/10-atmosphere.md) — свет, туман войны, трассеры, напряжение
|
||||
- [11-melee](docs/11-melee.md) — **ближний бой**: сектор удара, три фазы, свой круг
|
||||
|
||||
Исходное ТЗ: [SQUAD_PROTOTYPE_SPEC.md](SQUAD_PROTOTYPE_SPEC.md).
|
||||
|
||||
---
|
||||
|
||||
Проект вырос из шаблона [Raylib-CPP Starter Template for VS Code](https://youtu.be/acvgbKRaxDI);
|
||||
от шаблона остался только `Makefile`, который больше не используется — сборка через CMake.
|
||||
313
SQUAD_PROTOTYPE_SPEC.md
Normal file
313
SQUAD_PROTOTYPE_SPEC.md
Normal file
@ -0,0 +1,313 @@
|
||||
# Прототип: тактический отряд с авто-огнём (изометрия, software rendering)
|
||||
|
||||
Ты делаешь **технический прототип боевого ядра** для 2D-изометрического sci-fi horror roguelite / incremental ARPG.
|
||||
Цель прототипа — **только одно**: доказать, что отряд из 5 агентов ведёт себя как «умная текучая сущность», которая сама расставляется так, чтобы стрелять и не задевать своих.
|
||||
|
||||
Всё остальное (лут, прогрессия, враги-ИИ, уровни) — вне рамок. Не добавляй.
|
||||
|
||||
---
|
||||
|
||||
## 1. Технические ограничения
|
||||
|
||||
- **C++17**, **raylib 5.x**, **CMake** (raylib через `FetchContent`, если не найден в системе).
|
||||
- Никаких других зависимостей. Никакого ECS-фреймворка, никакой физдвижка.
|
||||
- **Software rendering.** Вся отрисовка сцены — ручная запись пикселей в собственный фреймбуфер (`uint32_t*`, RGBA8). Ни одного вызова raylib-примитивов для игровой сцены.
|
||||
- Из raylib используются только: окно, ввод, таймер, `UpdateTexture` + `DrawTexturePro` для финального блита фреймбуфера на экран (фильтрация `TEXTURE_FILTER_POINT`).
|
||||
- Исключение: **debug-оверлей** можно рисовать через `DrawText`/`DrawLine` поверх уже отмасштабированного кадра. Это dev-only, отключается по F1.
|
||||
- **Внутреннее разрешение 480×270**, окно 1440×810 (целочисленный ×3 апскейл). Разрешение и множитель — константы.
|
||||
- **Fixed timestep симуляции 60 Гц**, накопитель времени, рендер с интерполяцией позиций. Симуляция детерминирована.
|
||||
- Однопоточно. Читаемость важнее производительности, но 5 агентов + 12 мишеней + 200 пуль должны идти в 60 fps без проблем.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ассеты
|
||||
|
||||
**Никаких файлов.** Все спрайты генерируются процедурно в код при старте (пишешь пиксели в буфер):
|
||||
|
||||
- `tile_floor` — ромб 32×16, 2 варианта оттенка (шахматка), тёмно-серый металл.
|
||||
- `tile_wall` — блок 32×32 (ромб + вертикальные грани), заметно светлее пола.
|
||||
- `agent` — 10×14, капсула с обводкой, 5 цветовых вариантов (по одному на агента).
|
||||
- `target` — 12×16, тускло-красная капсула с обводкой.
|
||||
- `bullet` — 2×2 точка.
|
||||
|
||||
Спрайты хранятся как `Sprite { int w, h; std::vector<uint32_t> px; }` с альфа-тестом (0 = прозрачно), без блендинга.
|
||||
|
||||
---
|
||||
|
||||
## 3. Координаты и рендер
|
||||
|
||||
**Критично: вся симуляция — в декартовом мире (top-down), изометрия существует только в рендерере.**
|
||||
Никакой iso-математики в логике, ИИ или коллизиях.
|
||||
|
||||
- Мир в тайлах, `float` координаты. Тайл = 1.0 юнит.
|
||||
- Проекция: `sx = (wx - wy) * 16`, `sy = (wx + wy) * 8` (тайл 32×16), плюс смещение камеры.
|
||||
- Камера следует за якорем отряда со сглаживанием, округляется до целых пикселей (чтобы не дрожало).
|
||||
- Сортировка по глубине: все видимые сущности и стены в один список, сортировка по `(wx + wy)`, потом блит.
|
||||
- Пол рисуется первым сплошным проходом по видимой области карты.
|
||||
|
||||
---
|
||||
|
||||
## 4. Уровень
|
||||
|
||||
Один захардкоженный тайлмап ~48×48, генерируется в коде:
|
||||
|
||||
- открытая арена в центре;
|
||||
- **колонны/огрызки стен по всей карте** — они обязаны быть, без них не проверить LOS и обход;
|
||||
- пара узких проходов (шириной 2 тайла) — там правило «не стрелять сквозь своих» упирается в геометрию, это важный тест-кейс;
|
||||
- по периметру — сплошная стена.
|
||||
|
||||
Тайл: `EMPTY` (проходим, прозрачен) / `WALL` (непроходим, блокирует LOS и пули).
|
||||
|
||||
**Мишени:** 12 штук, статичные, разбросаны кучками (по 1, по 3, по 5). У мишени есть HP (например, 500) и она **респавнится через 3 сек** после смерти на том же месте — прототип должен работать бесконечно. Мишени не атакуют и не двигаются (на этом этапе это шум).
|
||||
|
||||
---
|
||||
|
||||
## 5. Управление (только клавиатура)
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| `WASD` | движение якоря отряда (в **экранных** осях, не мировых — иначе неинтуитивно) |
|
||||
| `Shift` (удерж.) | медленный шаг |
|
||||
| `Space` | переключить режим ручного прицеливания |
|
||||
| `←/→` | в ручном режиме — поворот вектора прицела |
|
||||
| `Tab` | сменить назначенную цель |
|
||||
| `1/2/3` | пресет строя: клин / линия / кольцо |
|
||||
| `F1` | debug-оверлей вкл/выкл |
|
||||
| `F2` | показ линий огня |
|
||||
| `F3` | показ кандидатов позиций и их оценок |
|
||||
| `F4` | метрики |
|
||||
| `R` | сброс уровня |
|
||||
| `Esc` | выход |
|
||||
|
||||
Стрельба **всегда автоматическая**. Игрок не нажимает «огонь» никогда, ни в одном режиме.
|
||||
|
||||
---
|
||||
|
||||
## 6. ЯДРО: поведение отряда
|
||||
|
||||
Это главная часть задания. Ниже — точная спецификация, реализуй её как **чистые, тестируемые функции**, отдельно от рендера.
|
||||
|
||||
### 6.1 Сущности
|
||||
|
||||
```cpp
|
||||
struct Agent {
|
||||
Vec2 pos, vel;
|
||||
float facing; // радианы, куда смотрит ствол
|
||||
float bodyRadius = 0.28f;
|
||||
int slotIndex; // место в строю
|
||||
AgentState state;
|
||||
int currentTargetId; // -1 = нет
|
||||
Vec2 postPos; // куда агент решил встать (огневая позиция)
|
||||
float commitTimer; // блокировка перерешивания
|
||||
float solveTimer; // стаггер решателя
|
||||
Weapon weapon;
|
||||
};
|
||||
|
||||
struct Weapon {
|
||||
float range = 9.0f;
|
||||
float fireInterval = 0.35f;
|
||||
float cooldown;
|
||||
float laneHalfWidth = 0.10f; // «толщина» траектории пули
|
||||
float damage = 12.0f;
|
||||
};
|
||||
```
|
||||
|
||||
### 6.2 Состояния отряда
|
||||
|
||||
- `ADVANCING` — есть ввод движения.
|
||||
- `HOLDING` — ввода нет дольше `0.15 сек`.
|
||||
|
||||
### 6.3 Проверка линии огня (сердце всей механики)
|
||||
|
||||
```
|
||||
bool LaneClear(shooter, targetPos, allAgents, tilemap):
|
||||
seg = Segment(shooter.muzzle(), targetPos)
|
||||
|
||||
// 1. свои
|
||||
for each ally in allAgents, ally != shooter:
|
||||
d = DistancePointToSegment(ally.pos, seg)
|
||||
if d < ally.bodyRadius + shooter.weapon.laneHalfWidth + SAFETY_MARGIN:
|
||||
return false
|
||||
|
||||
// 2. геометрия
|
||||
if TileRaycastBlocked(seg, tilemap):
|
||||
return false
|
||||
|
||||
// 3. дистанция
|
||||
if seg.length() > shooter.weapon.range:
|
||||
return false
|
||||
|
||||
return true
|
||||
```
|
||||
|
||||
`SAFETY_MARGIN = 0.15f` (тюнится). `muzzle()` — точка в `0.3` юнита от центра агента по `facing`.
|
||||
|
||||
Рейкаст по тайлам — Amanatides & Woo (DDA), без выделений памяти.
|
||||
|
||||
### 6.4 Логика в состоянии `ADVANCING`
|
||||
|
||||
- Каждый агент движется к своему **слоту строя** относительно якоря, строй ориентирован по направлению движения.
|
||||
- Стрельба **оппортунистическая**: стреляет только тот, у кого прямо сейчас `LaneClear` до какой-либо цели в радиусе. Остальные просто бегут.
|
||||
- Приоритет цели у бегущего: ближайшая, до которой линия чиста.
|
||||
- **Никакого перестроения ради огня** в этом состоянии — отряд едет туда, куда сказал игрок.
|
||||
- Работает уклонение от чужих линий (см. 6.6) — агент, влезший в чужой сектор обстрела, смещается.
|
||||
|
||||
### 6.5 Логика в состоянии `HOLDING` — решатель огневых позиций
|
||||
|
||||
Запускается **не каждый кадр**: у каждого агента свой `solveTimer` (интервал `0.25 сек`), фазы сдвинуты так, чтобы за кадр решал максимум 1 агент.
|
||||
|
||||
Условие запуска: у агента **нет** валидной линии огня ни к одной живой цели в радиусе.
|
||||
|
||||
**Кандидаты позиций:**
|
||||
- текущая позиция (для сравнения);
|
||||
- 3 кольца радиусами `0.8 / 1.6 / 2.4` юнита × 12 направлений = 36 точек.
|
||||
|
||||
Отбрасываются кандидаты: в стене, ближе `0.55` юнита к другому агенту, вне карты.
|
||||
|
||||
**Оценка кандидата:**
|
||||
|
||||
```
|
||||
score = 0
|
||||
if LaneClearFrom(candidate, bestTarget): score += 100
|
||||
score += 25 * (число целей, простреливаемых из candidate) / totalTargets
|
||||
score -= 35 * (число союзников, чью текущую валидную линию перекрывает candidate)
|
||||
score -= 9 * distance(candidate, agent.pos)
|
||||
score -= 14 * max(0, distance(candidate, anchor) - COHESION_RADIUS)
|
||||
if distance(candidate, ближайшая цель) < MIN_ENGAGE_DIST: score -= 60
|
||||
```
|
||||
|
||||
**Коммит с гистерезисом (обязательно, иначе будет дёрганье):**
|
||||
|
||||
```
|
||||
if bestScore > currentPositionScore + HYSTERESIS(15.0):
|
||||
agent.postPos = bestCandidate
|
||||
agent.commitTimer = 0.6f // не перерешивать это время
|
||||
```
|
||||
|
||||
`COHESION_RADIUS = 3.5`, `MIN_ENGAGE_DIST = 2.0`.
|
||||
|
||||
Пока `commitTimer > 0` — агент идёт к `postPos` и не пересчитывает.
|
||||
|
||||
### 6.6 Резервирование линий и расступание («текучесть»)
|
||||
|
||||
Каждый кадр собирается список **активных линий огня** — сегментов от стреляющих агентов к их целям.
|
||||
|
||||
Любой агент, чьё тело попадает в чужую активную линию (`dist < bodyRadius + laneHalfWidth + SAFETY_MARGIN`), получает **steering-импульс перпендикулярно линии**, в сторону ближайшего края. Сила импульса растёт при приближении к оси.
|
||||
|
||||
Это работает в **обоих** состояниях отряда и даёт главное ощущение: отряд сам «расплывается», освобождая сектора обстрела, без единой явной команды.
|
||||
|
||||
### 6.7 Движение агента (слои, складываются в таком порядке)
|
||||
|
||||
1. `seek` к целевой точке (слот строя или `postPos`);
|
||||
2. `separation` от союзников (радиус `0.7`);
|
||||
3. `laneAvoidance` (6.6) — **вес выше, чем у separation**;
|
||||
4. `wallAvoidance` + скольжение вдоль стен при коллизии;
|
||||
5. клампинг по максимальной скорости, интеграция, разрешение коллизий с тайлами (circle-vs-AABB, push-out).
|
||||
|
||||
Скорость: `3.2` юнита/сек, `1.6` на Shift.
|
||||
|
||||
### 6.8 Ручное прицеливание (`Space`)
|
||||
|
||||
- Игрок вращает вектор прицела стрелками.
|
||||
- **Назначенная цель** — ближайшая мишень в конусе ±20° от вектора прицела. `Tab` циклит между целями в конусе.
|
||||
- Все агенты приоритезируют назначенную цель.
|
||||
- **Правила 6.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 реализуется как написано. Если считаешь, что где-то ошибка — сначала скажи, потом делай.
|
||||
57
docs/00-overview.md
Normal file
57
docs/00-overview.md
Normal file
@ -0,0 +1,57 @@
|
||||
# 00 — Что это
|
||||
|
||||
Технический прототип **боевого ядра** 2D-изометрического sci-fi horror roguelite.
|
||||
Доказывает ровно одну вещь:
|
||||
|
||||
> отряд из 5 агентов ведёт себя как «умная текучая сущность», которая сама
|
||||
> расставляется так, чтобы атаковать и не задевать своих.
|
||||
|
||||
Всё остальное — вне рамок и намеренно не сделано.
|
||||
|
||||
## Что есть
|
||||
|
||||
- 5 агентов, один диск отряда (якорь), три пресета строя (клин / линия / кольцо).
|
||||
- Полностью **автоматическая** атака: игрок не нажимает «огонь» никогда.
|
||||
- Проверка линии огня по своим, по стенам и по дальности (спек 6.3).
|
||||
- Решатель огневых позиций с гистерезисом и коммитом (спек 6.5).
|
||||
- Резервирование линий огня и расступание из чужих секторов (спек 6.6).
|
||||
- Настоящие пули-снаряды, дружественный урон физически возможен (спек 6.9).
|
||||
- Ручное прицеливание с назначенной целью и `Tab` (спек 6.8).
|
||||
- **Ближний бой**: второй класс оружия с сектором замаха вместо линии огня,
|
||||
тремя фазами удара и своим, большим кругом вокруг диска
|
||||
([11-melee.md](11-melee.md)). В спеке его нет.
|
||||
- Хоррор-слой: свет, туман войны, трассеры, напряжение
|
||||
([10-atmosphere.md](10-atmosphere.md)). В спеке его тоже нет.
|
||||
- Debug-оверлей, метрики корректности, runtime-тюнинг.
|
||||
|
||||
## Чего нет и не будет
|
||||
|
||||
Врагов с ИИ, лута, прогрессии, UI, меню, звука, ECS, сторонних библиотек.
|
||||
Мишени статичны и не атакуют — это шум для задачи.
|
||||
|
||||
Свет, туман войны и эффекты в исходных рамках тоже стояли в этом списке, но
|
||||
появились вместе с хоррор-слоем. Правило, по которому их пустили: они живут
|
||||
только в `render/`, `RenderGame` принимает `const Game&`, и на симуляцию повлиять
|
||||
физически не могут. Ближний бой — единственное добавление, которое **меняет**
|
||||
правила, поэтому он проверяется собственными критериями приёмки (M7.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).
|
||||
112
docs/01-build-and-run.md
Normal file
112
docs/01-build-and-run.md
Normal file
@ -0,0 +1,112 @@
|
||||
# 01 — Сборка, запуск, управление
|
||||
|
||||
## Требования
|
||||
|
||||
- CMake 3.16+
|
||||
- Компилятор с C++17. Проверено на MSVC 14.44 (Visual Studio 2022, x64).
|
||||
- Интернет при **первой** конфигурации: raylib 6.0 качается через `FetchContent`.
|
||||
Если в системе уже стоит raylib 6.x, `find_package(raylib 6)` возьмёт её и ничего не качает.
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
cmake -S . -B build # разово; тянет и собирает raylib (~1-2 мин)
|
||||
cmake --build build --config Release --parallel
|
||||
```
|
||||
|
||||
Бинарник: `build/bin/squad_proto.exe` (при любом генераторе, включая multi-config).
|
||||
|
||||
Debug-сборка (`--config Debug`) кладёт бинарник туда же и идёт в 60 fps, так что
|
||||
проверять правки стоит именно в ней: MSVC включает проверки итераторов и ловит
|
||||
выходы за границы, которых в Release не видно.
|
||||
|
||||
В VS Code: `F5` — конфигурация **Run (Release)** из `.vscode/launch.json`,
|
||||
сборка идёт задачей `build release`. Есть также задачи `accept` и `headless 180s`.
|
||||
|
||||
> `Makefile` в корне остался от исходного шаблона raylib-vscode и **не используется** —
|
||||
> он ссылается на `C:/raylib/w64devkit`. Актуальная сборка только через CMake.
|
||||
|
||||
### Если CMake ругается на генератор
|
||||
|
||||
```
|
||||
CMake Error: Error: generator : Visual Studio 17 2022
|
||||
Does not match the generator used previously: Visual Studio 18 2026
|
||||
```
|
||||
|
||||
Каталог `build/` привязан к конкретному генератору, и на машине с несколькими
|
||||
Visual Studio его легко получить несогласованным: обычный `cmd.exe` и
|
||||
Developer Command Prompt выбирают разные версии по умолчанию. Лечится сносом
|
||||
каталога — он целиком производный, в нём нет ничего своего:
|
||||
|
||||
```bash
|
||||
rmdir /s /q build # cmd
|
||||
rm -rf build # bash
|
||||
cmake -S . -B build
|
||||
```
|
||||
|
||||
Чтобы зафиксировать версию явно, передай генератор при первой конфигурации:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build -G "Visual Studio 17 2022" -A x64
|
||||
```
|
||||
|
||||
Дальше `cmake -S . -B build` без `-G` будет переиспользовать генератор из кэша.
|
||||
`build/` намеренно в `.gitignore` — в репозитории его быть не должно.
|
||||
|
||||
## Управление
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| `WASD` | движение диска (якоря отряда) в **экранных** осях |
|
||||
| `Shift` (удерж.) | рывок (базовый шаг намеренно медленный) |
|
||||
| `Space` | переключить режим ручного прицеливания |
|
||||
| `←` / `→` | в ручном режиме — поворот вектора прицела |
|
||||
| `Tab` | сменить назначенную цель (внутри конуса прицела) |
|
||||
| `1` / `2` / `3` | строй: клин / линия / кольцо |
|
||||
| `F1` | оверлей вкл/выкл |
|
||||
| `F2` | линии огня |
|
||||
| `F3` | кандидаты решателя и их оценки |
|
||||
| `F4` | метрики |
|
||||
| `F5` | выключить хоррор-слой (свет/туман/пост) |
|
||||
| `F6` | показать диски обзора |
|
||||
| `[` / `]` | выбрать параметр тюнинга |
|
||||
| `-` / `=` | изменить выбранный параметр |
|
||||
| `R` | сброс уровня |
|
||||
| `Esc` | выход |
|
||||
|
||||
Стрельба **всегда автоматическая**, клавиши огня нет ни в одном режиме.
|
||||
Ближний бой тоже: бойцы с клинком сами доходят до цели и бьют, пока она внутри
|
||||
их круга ([11-melee.md](11-melee.md)).
|
||||
|
||||
Диск намеренно быстрее бойцов и держится к ним на поводке: команда читается
|
||||
мгновенно, а средняя скорость похода всё равно определяется ногами отряда.
|
||||
|
||||
## Dev-режимы командной строки
|
||||
|
||||
Симуляция детерминирована и не зависит от рендера, поэтому её можно гонять без окна.
|
||||
|
||||
| Аргумент | Что делает |
|
||||
|---|---|
|
||||
| `--accept` | прогоняет сценарии критериев приёмки (спек 11) и печатает PASS/FAIL |
|
||||
| `--headless <сек>` | прогон по маршруту без окна + метрики каждые 10 с |
|
||||
| `--probe` | к `--headless`: подробности по каждому агенту на стоянках |
|
||||
| `--no-solver` | отключить решатель огневых позиций (A/B сравнение) |
|
||||
| `--no-laneavoid` | отключить расступание из чужих линий (A/B сравнение) |
|
||||
| `--melee N` | сколько слотов строя держат клинок (по умолчанию 2, `0` = все стрелки) |
|
||||
| `--set имя=значение` | подкрутить любой параметр из `TUNABLES` без пересборки |
|
||||
| `--shot [кадров] [файл]` | снять скриншот через N кадров и выйти |
|
||||
| `--clean` | стартовать без оверлея — чистый кадр для скриншотов |
|
||||
|
||||
Примеры:
|
||||
|
||||
```bash
|
||||
squad_proto.exe --accept
|
||||
squad_proto.exe --headless 300
|
||||
squad_proto.exe --headless 300 --no-solver --no-laneavoid # каким прототип был бы без ядра
|
||||
squad_proto.exe --headless 300 --melee 0 # чистый отряд стрелков
|
||||
squad_proto.exe --headless 300 --set safetyMargin=0.25 --set laneReserve=2.0
|
||||
squad_proto.exe --clean --shot 220 melee.png # кадр без отладки
|
||||
```
|
||||
|
||||
Код прогонов — [src/debug/harness.cpp](../src/debug/harness.cpp), маршрут и сценарии описаны там же.
|
||||
Что означают цифры в выводе — [06-debug-and-tuning.md](06-debug-and-tuning.md).
|
||||
103
docs/02-architecture.md
Normal file
103
docs/02-architecture.md
Normal file
@ -0,0 +1,103 @@
|
||||
# 02 — Архитектура
|
||||
|
||||
## Карта модулей
|
||||
|
||||
```
|
||||
src/
|
||||
main.cpp окно, ввод, главный цикл, fixed timestep. Игровой логики нет
|
||||
game.h/.cpp склейка: карта + отряд + мишени + пули + метрики, порядок шага
|
||||
tuning.h ВСЕ числа прототипа + таблица runtime-подкрутки
|
||||
core/
|
||||
math.h Vec2, отрезки, DistancePointToSegment, углы. Header-only
|
||||
input.h InputState — ввод, влияющий на симуляцию
|
||||
render/
|
||||
view.h/.cpp ВСЁ render-only состояние + RenderGame(const Game&, ...)
|
||||
framebuffer.h/.cpp софтверный буфер RGBA8, блит (в т.ч. со светом), аддитивные примитивы
|
||||
sprites.h/.cpp процедурная генерация всех спрайтов при старте
|
||||
iso.h проекция мир↔экран, камера, ключ сортировки по глубине
|
||||
scene.h/.cpp проход по полу + общий список сущностей и стен с сортировкой
|
||||
lighting.h/.cpp свет и туман войны (см. docs/10-atmosphere.md)
|
||||
fx.h/.cpp трассеры, вспышки дула, искры попаданий
|
||||
post.h/.cpp напряжение (Dread) и слитый пост-проход
|
||||
noise.h единственное место render-only псевдослучайности
|
||||
world/
|
||||
tilemap.h/.cpp карта, генерация уровня, DDA-рейкаст, circle-vs-AABB
|
||||
sim/
|
||||
loadout.h класс оружия, параметры ствола и клинка, фазы удара
|
||||
agent.h/.cpp агент, слои движения (seek/separation/targetPush/wallAvoid)
|
||||
squad.h/.cpp якорь и поводок, строй, ADVANCING/HOLDING, сборка слоёв
|
||||
target.h/.cpp мишени, респавн, отдача от удара и возврат на место
|
||||
bullet.h/.cpp снаряды, коллизии с тайлами и капсулами
|
||||
events.h кольцевой журнал боевых событий: единственный канал к эффектам
|
||||
ai/
|
||||
firing_solver.h/.cpp LaneClear, выбор цели, решатель огневых позиций
|
||||
lane_registry.h/.cpp активные линии огня и секторы клинков, импульс расступания
|
||||
melee.h/.cpp цель ближнего боя, подход вплотную, чистота сектора
|
||||
debug/
|
||||
overlay.h/.cpp оверлей 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).
|
||||
102
docs/03-rendering.md
Normal file
102
docs/03-rendering.md
Normal file
@ -0,0 +1,102 @@
|
||||
# 03 — Рендер: софтверный буфер, изометрия, спрайты
|
||||
|
||||
## Фреймбуфер
|
||||
|
||||
[src/render/framebuffer.h](../src/render/framebuffer.h) — `std::vector<uint32_t>` 480×270.
|
||||
|
||||
Формат — RGBA8, в памяти байты идут `R,G,B,A`, значит little-endian `uint32` это
|
||||
`A<<24 | B<<16 | G<<8 | R`. Помощник `RGBA(r,g,b,a)` собирает цвет правильно.
|
||||
|
||||
Примитивы: `Clear`, `Put`, `Blit`, `BlitTinted`, `FillRect`, `Line` (Брезенхем),
|
||||
`CircleOutline` (средняя точка), `CircleFilled`. Всё пишет пиксели руками.
|
||||
|
||||
Спрайты пишутся **альфа-тестом**: пиксель с `alpha == 0` не пишется, полупрозрачности
|
||||
нет. Для эффектов есть **аддитивное** смешивание с насыщением (`AddSat`, `AddBeam`,
|
||||
`AddGlowDisc`) и умножение (`ScaleColor8`, `BlitMul`), а для света — `BlitShaded`,
|
||||
пропускающий цвет через световую рампу. Подробности — [10-atmosphere.md](10-atmosphere.md).
|
||||
|
||||
`Blit` обрезает спрайт по границам буфера ДО циклов и **выходит при пустом
|
||||
диапазоне**: иначе адрес строки вычисляется за концом вектора. Цикл при этом не
|
||||
выполняется, разыменования нет, но индекс уже невалиден — Debug-сборка ловит это
|
||||
как `vector subscript out of range`, Release молча считает мусорный адрес.
|
||||
|
||||
Вывод на экран — `ScreenBlitter`: `UpdateTexture` + `DrawTexturePro` с
|
||||
`TEXTURE_FILTER_POINT`. Это единственное место рендера, знающее про raylib;
|
||||
`Texture2D` спрятан за `void*`, чтобы `raylib.h` не протёк в заголовок.
|
||||
|
||||
## Изометрия
|
||||
|
||||
[src/render/iso.h](../src/render/iso.h). Тайл 32×16:
|
||||
|
||||
```
|
||||
sx = (wx - wy) * 16
|
||||
sy = (wx + wy) * 8
|
||||
```
|
||||
|
||||
Обратная проекция: `wx = sx/32 + sy/16`, `wy = sy/16 - sx/32`.
|
||||
|
||||
`ScreenDirToWorld` переводит направление ввода из экранных осей в мировые —
|
||||
именно это делает `WASD` интуитивным. Больше изометрия нигде не используется.
|
||||
|
||||
**Камера** следует за интерполированным якорем экспоненциальным сглаживанием
|
||||
(`1 - exp(-smooth*dt)`), смещение округляется до целых пикселей — иначе
|
||||
при ×3 апскейле картинка дрожит.
|
||||
|
||||
## Сортировка по глубине
|
||||
|
||||
Ключ — `wx + wy`. Пол рисуется первым сплошным проходом и в сортировке не участвует.
|
||||
Все стены и все сущности попадают в **один** список `Scene::items` и сортируются
|
||||
`std::stable_sort` (стабильная — чтобы порядок был детерминирован при равных ключах).
|
||||
|
||||
Отсечение — по экранным координатам с запасом на высоту спрайта стены.
|
||||
|
||||
## Точка привязки спрайта
|
||||
|
||||
У `Sprite` есть поля `ax, ay` — точка внутри спрайта, которая совмещается с
|
||||
экранной проекцией мировой позиции. Так блит не зависит от размера спрайта:
|
||||
|
||||
| Спрайт | Размер | Привязка | Смысл |
|
||||
|---|---|---|---|
|
||||
| пол | 32×16 | (16, 8) | центр ромба = центр тайла |
|
||||
| стена | 32×32 | (16, 24) | низ блока садится на ромб пола |
|
||||
| агент (стрелок) | 10×14 | (5, 12) | «ноги» на земле |
|
||||
| агент (клинок) | 12×15 | (6, 13) | шире в плечах, класс читается силуэтом |
|
||||
| мишень | 12×16 | (6, 14) | то же |
|
||||
|
||||
У пули спрайта нет: снаряд рисуется светящимся трассером, см. [10-atmosphere.md](10-atmosphere.md).
|
||||
|
||||
## Процедурные спрайты
|
||||
|
||||
[src/render/sprites.cpp](../src/render/sprites.cpp), файлов с ассетами нет.
|
||||
|
||||
- **Пол** — ромб 32×16, два оттенка тёмно-серого металла по шахматке `(x+y)&1`,
|
||||
затемнённый рант по краю и редкие светлые крапины для фактуры.
|
||||
- **Стена** — верхняя грань (тот же ромб) + вертикальные грани: для каждого столбца
|
||||
ищется нижняя кромка ромба и вниз заливается на высоту тайла. Левая грань темнее
|
||||
правой. Обводка по силуэту — пиксели, у которых есть прозрачный 4-сосед.
|
||||
- **Агент** — капсула: пиксель внутри, если расстояние до вертикального отрезка
|
||||
меньше радиуса; внешний пиксель кольца — обводка, слева-сверху блик.
|
||||
5 различимых цветов, по одному на агента.
|
||||
- **Агент с клинком** — та же капсула, крупнее, плюс стальная вертикаль с бликом
|
||||
и гардой у правого края. Цвет уже занят номером агента, поэтому класс обязан
|
||||
читаться формой. Сам взмах рисуется не спрайтом, а аддитивной дугой
|
||||
([11-melee.md](11-melee.md)).
|
||||
- **Мишень** — та же капсула, тускло-красная, 12×16. В темноте тело не видно —
|
||||
виден только тлеющий красный «глаз», нарисованный аддитивно поверх сцены.
|
||||
|
||||
Порядок отрисовки кадра:
|
||||
|
||||
```
|
||||
Clear(чёрный) → свет → пол (BlitShaded) → список (стены, мишени, агенты)
|
||||
→ сортировка → блит → глаза мишеней → трассеры, вспышки и дуги клинков
|
||||
(аддитивно) → ОДИН пост-проход → Present → оверлей (raylib, dev-only)
|
||||
```
|
||||
|
||||
Три инварианта, нарушение любого ломает картинку молча:
|
||||
|
||||
1. альфа фреймбуфера всегда 255;
|
||||
2. ступень 0 световой рампы совпадает с цветом `Clear` — только поэтому полностью
|
||||
тёмные тайлы можно не рисовать;
|
||||
3. стена никогда не темнее самого яркого соседа-пола — иначе сквозь пропущенный
|
||||
тёмный блок проступит освещённый пол тайла (tx−1, ty−1), который рисуется
|
||||
раньше по глубине.
|
||||
80
docs/04-world.md
Normal file
80
docs/04-world.md
Normal file
@ -0,0 +1,80 @@
|
||||
# 04 — Мир: карта, рейкаст, коллизии, мишени
|
||||
|
||||
## Тайлмап
|
||||
|
||||
[src/world/tilemap.h](../src/world/tilemap.h). Сетка 48×48, тайл `EMPTY` или `WALL`.
|
||||
Тайл = 1.0 юнита, координаты сущностей — `float`.
|
||||
|
||||
**Вне карты считается стеной.** Это убирает граничные проверки из рейкаста и коллизий.
|
||||
|
||||
## Планировка уровня
|
||||
|
||||
Генерируется кодом, без RNG, полностью детерминированно (`Tilemap::Generate`).
|
||||
|
||||
```
|
||||
y 0..1 сплошная рамка (2 тайла по всему периметру)
|
||||
y 2..12 северная комната: кучка из 3 мишеней + колонны
|
||||
y 13..15 стена-разделитель на всю ширину
|
||||
проход A: x=10..11 проход B: x=30..31 (оба шириной 2 тайла)
|
||||
y 16..45 центральная арена: кучка из 5 мишеней, колонны, огрызки стен, блок 2x2
|
||||
x 12..14 западная стена-разделитель (y 20..44), проход: y=30..31
|
||||
x 38..40 восточная стена-разделитель (y 18..40), проход: y=27..28
|
||||
```
|
||||
|
||||
Отряд стартует в `(24, 26)` — открытая арена, кучка из 5 мишеней в радиусе оружия.
|
||||
|
||||
Колонны и огрызки стен раскиданы по всей карте намеренно: без них не проверить
|
||||
ни LOS, ни обход. Три прохода шириной 2 тайла — это тест-кейс, где правило
|
||||
«не стрелять сквозь своих» упирается в геометрию.
|
||||
|
||||
Северная кучка мишеней стоит **напротив прохода B** (`x=29.5/31.5`), так что из
|
||||
коридора до неё есть чистая линия. Без этого проверить критерий «в узком проходе
|
||||
отряд вытягивается в колонну, стреляет передний» было бы не на чем.
|
||||
|
||||
Кучка обязана быть **в пределах дальности от середины прохода** (`y≈14`), иначе
|
||||
тест-кейс проверяет пустоту. При укорочении дальности до 7.2 её пришлось
|
||||
подтянуть на два тайла к югу (`y` 5.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`): до
|
||||
ближнего боя агент мог стоять внутри мишени, и удар не читался вообще.
|
||||
254
docs/05-squad-ai.md
Normal file
254
docs/05-squad-ai.md
Normal file
@ -0,0 +1,254 @@
|
||||
# 05 — Ядро: поведение отряда
|
||||
|
||||
Главный файл проекта. Всё здесь — чистые функции над состоянием мира,
|
||||
без рендера и изометрии: [src/ai/firing_solver.cpp](../src/ai/firing_solver.cpp),
|
||||
[src/ai/lane_registry.cpp](../src/ai/lane_registry.cpp), [src/sim/squad.cpp](../src/sim/squad.cpp).
|
||||
|
||||
Этот файл описывает поведение **стрелка**. Боец ближнего боя живёт по тем же
|
||||
правилам в угловой форме — [11-melee.md](11-melee.md).
|
||||
|
||||
## Состояния отряда
|
||||
|
||||
- `ADVANCING` — есть ввод движения.
|
||||
- `HOLDING` — ввода нет дольше `holdDelay` (0.15 с).
|
||||
|
||||
Состояние общее для отряда, копируется в каждого агента. При смене состояния
|
||||
сбрасываются `commitTimer`, а при входе в `HOLDING` каждому агенту ставится
|
||||
`postPos = его текущая позиция`: отряд встал там, где встал, дальше его
|
||||
расставляет решатель.
|
||||
|
||||
## 1. Проверка линии огня — `LaneClearFrom`
|
||||
|
||||
Сердце всей механики. Порядок проверок ровно как в спеке 6.3:
|
||||
|
||||
```
|
||||
muzzle = from + normalize(target - from) * muzzleOffset // 0.3 юнита
|
||||
|
||||
1. свои: для каждого союзника (кроме стрелка)
|
||||
если DistancePointToSegment(ally.pos, muzzle, target)
|
||||
< ally.bodyRadius + laneHalfWidth + safetyMargin -> нет линии
|
||||
2. геометрия: если DDA-рейкаст muzzle->target упёрся в стену -> нет линии
|
||||
3. дистанция: если |muzzle - target| > weaponRange -> нет линии
|
||||
```
|
||||
|
||||
Порог по умолчанию: `0.28 + 0.10 + 0.15 = 0.53` юнита.
|
||||
|
||||
Функция принимает **произвольную точку** `from`, а не только позицию агента —
|
||||
поэтому ей же оценивается «а если я встану вот сюда» в решателе.
|
||||
|
||||
Дуло считается в сторону цели, а не по текущему `facing`. Это важно: пуля
|
||||
вылетает из той же точки и в том же направлении, что проверялось.
|
||||
|
||||
## 2. Выбор цели
|
||||
|
||||
`EvaluateAgentLane` — один проход на агента за шаг:
|
||||
|
||||
- **Авто-режим**: ближайшая живая мишень в радиусе, до которой линия чиста.
|
||||
- **Ручной режим** (спек 6.8): сначала назначенная цель. Если до неё линии нет:
|
||||
- в `HOLDING` агент молчит и перестраивается решателем (цель решателя = назначенная);
|
||||
- в `ADVANCING` стреляет по вторичным целям, только если `freeFireSecondary` (по умолчанию да).
|
||||
|
||||
Побочно заполняются флаги для оверлея: `targetBlockedByAlly`, `targetBlockedByWall`,
|
||||
и `laneIntent` — «цель есть, мешает только свой» (см. пункт 4).
|
||||
|
||||
## 3. Решатель огневых позиций (`HOLDING`)
|
||||
|
||||
`SolveFiringPosition`. Запускается **не каждый кадр**: у каждого агента свой
|
||||
`solveTimer` с интервалом 0.25 с, фазы разведены при старте так, что за кадр
|
||||
решает максимум один агент.
|
||||
|
||||
Условия запуска: состояние `HOLDING`, `commitTimer == 0`, и у агента **нет**
|
||||
линии к приоритетной цели.
|
||||
|
||||
**Кандидаты**: текущая позиция (индекс 0, для сравнения) + 3 кольца
|
||||
радиусами 0.8 / 1.6 / 2.4 × 12 направлений = 37 точек.
|
||||
|
||||
**Отбраковка**: вне карты, в стене, ближе 0.55 юнита к другому агенту.
|
||||
Текущая позиция не отбраковывается никогда — она база сравнения.
|
||||
|
||||
**Оценка** (`ScoreCandidate`, все веса в `tuning.h`):
|
||||
|
||||
```
|
||||
+100 если из кандидата чиста линия до основной цели
|
||||
+25 * (простреливаемых целей / всего живых целей)
|
||||
-35 * (число союзников, чью текущую линию перекрывает кандидат)
|
||||
- 9 * расстояние(кандидат, текущая позиция)
|
||||
-24 * max(0, расстояние(кандидат, якорь) - cohesionRadius 2.1)
|
||||
-60 если ближайшая цель ближе minEngageDist 1.6
|
||||
```
|
||||
|
||||
**Жёсткая граница**: кандидат дальше `cohesionRadius + postLeash` (2.1 + 1.0) от
|
||||
якоря отбраковывается совсем. Одной цены не хватает — линия огня стоит 100 очков,
|
||||
и за неё решатель готов уйти сколько угодно далеко. Замер: без жёсткой границы
|
||||
стрелок отходил от диска на 3.65 юнита, с ней — на 2.81, а `FIRE UPTIME` на
|
||||
маршруте вырос, а не упал (отряд плотнее — линий больше).
|
||||
|
||||
**Коммит с гистерезисом** — без него агент дёргается:
|
||||
|
||||
```
|
||||
если bestScore > currentScore + 15:
|
||||
postPos = лучший кандидат
|
||||
commitTimer = 0.6 // это время не перерешивать
|
||||
```
|
||||
|
||||
Пока `commitTimer > 0`, агент просто идёт к `postPos`.
|
||||
|
||||
Если целей в досягаемости нет вообще, решателю не над чем работать — тогда
|
||||
`postPos` = слот строя, чтобы отставший агент возвращался к отряду
|
||||
(случай, которого нет в спеке, см. [08-decisions.md](08-decisions.md)).
|
||||
|
||||
## 4. Резервирование линий и расступание
|
||||
|
||||
[src/ai/lane_registry.cpp](../src/ai/lane_registry.cpp). Каждый шаг собирается список
|
||||
активных линий — отрезков «дуло → цель». В реестр попадают:
|
||||
|
||||
- агенты с чистой линией (реально стреляющие);
|
||||
- **агенты, чью линию перекрыл только свой** (`laneIntent`).
|
||||
|
||||
Второй пункт — важное уточнение против буквального текста спека. Если
|
||||
регистрировать только реально стреляющих, то стоит кому-то влезть в линию —
|
||||
у стрелка линия пропадает, отрезок исчезает из реестра, и **перекрывшего ничто
|
||||
не выталкивает**. Сектор обстрела не освобождается никогда. Подробно и с цифрами —
|
||||
[08-decisions.md](08-decisions.md).
|
||||
|
||||
Импульс: агент, попавший в чужую линию, получает силу **перпендикулярно** линии
|
||||
в сторону ближайшего края; сила растёт при приближении к оси
|
||||
(`(1 - d/threshold) * laneAvoidStrength`, с насыщением до 1).
|
||||
|
||||
Порог резерва шире, чем порог «тело в линии» из 6.3, в `laneReserveScale` раз
|
||||
(по умолчанию 1.8). Причина — там же в 08-decisions: резерв должен отжимать
|
||||
агента **до** того, как он влез под уже летящую пулю.
|
||||
|
||||
Работает в обоих состояниях отряда. Это и даёт главное ощущение: отряд
|
||||
«расплывается» сам, без единой явной команды.
|
||||
|
||||
## 5. Движение агента — слои
|
||||
|
||||
`Squad::UpdateMovement`, порядок из спека 6.7:
|
||||
|
||||
| # | Слой | Вес | Файл |
|
||||
|---|---|---|---|
|
||||
| 1 | `seek` к цели (слот строя или `postPos`), с торможением у цели | `wSeek` 1.0 | `agent.cpp` |
|
||||
| 2 | `separation` от союзников, радиус 0.7 | `wSeparation` 1.0 | `agent.cpp` |
|
||||
| 3 | `laneAvoidance` — расступание из чужих линий и секторов | `wLaneAvoid` **2.4** | `lane_registry.cpp` |
|
||||
| 3′ | `targetPush` — мишень тоже тело, а не пустое место | `wTargetPush` 1.0 | `agent.cpp` |
|
||||
| 4 | `wallAvoidance` + касательное скольжение | `wWallAvoid` 1.2 | `agent.cpp` |
|
||||
| 5 | клампинг скорости, интеграция, разрешение коллизий | — | `agent.cpp` |
|
||||
|
||||
Вес расступания намеренно выше веса separation — так требует спек.
|
||||
|
||||
Слой 3′ добавлен вместе с ближним боем: без него боец стоял **внутри** того,
|
||||
кого рубит. Стрелков он тоже касается — они перестали проходить сквозь мишени.
|
||||
|
||||
Боец ближнего боя в активном окне удара весь этот стек пропускает: он едет по
|
||||
оси удара со своей скоростью и своим ускорением, остаются только стены.
|
||||
|
||||
Слой 4 не только отталкивает от стены, но и добавляет **касательную** составляющую
|
||||
в сторону цели, когда стена стоит между агентом и целью. Без этого seek и
|
||||
отталкивание уравновешиваются, и агент залипает в «тени» препятствия — это
|
||||
реально ловилось в прогонах (агент оставался в комнате, когда отряд ушёл).
|
||||
|
||||
Скорость 1.75 юнита/с, 2.7 на `Shift`. Скорость подтягивается к желаемой с
|
||||
ограничением `steerAccel`, чтобы резкие развороты не телепортировали агента.
|
||||
|
||||
## 5a. Диск и поводок
|
||||
|
||||
Якорь («диск», за которым идёт камера) движется **быстрее** бойцов: 3.3 против
|
||||
1.75, на `Shift` 4.8 против 2.7. Это отвечает за отзывчивость: старт и разворот
|
||||
читаются мгновенно, а не через полсекунды инерции пятерых.
|
||||
|
||||
Само по себе это ломало бы отряд — диск просто уезжал бы вперёд, слоты строя за
|
||||
ним, и агенты вечно бежали бы в хвосте. Поэтому есть **поводок**, и он
|
||||
**упругий**, а не жёсткий:
|
||||
|
||||
| Параметр | Смысл |
|
||||
|---|---|
|
||||
| `anchorLeash` (2.4) | мягкая граница: до неё диск идёт свободно |
|
||||
| `anchorLeashMax` (3.6) | жёсткий предел: страховка, дальше не пускает вообще |
|
||||
| `leashDrag` (0.75) | между границами диск тяжелеет; на упоре остаётся 25 % скорости |
|
||||
|
||||
Жёсткий поводок здесь не годился принципиально: он мгновенно упирается,
|
||||
дистанция намертво встаёт в одно значение, и по ней уже нельзя отличить
|
||||
«подправил направление» от «тяну отряд вперёд изо всех сил». Упругий поводок
|
||||
делает дистанцию непрерывной величиной — а из неё считается **натяжение**
|
||||
(`urgency`), которое разгоняет отряд, растягивает строй и на верхней трети шкалы
|
||||
(`sprintAt`) прекращает огонь вовсе.
|
||||
|
||||
Итог: «быстро» — это отзывчивость на старте и на развороте, а средняя скорость
|
||||
похода всё равно определяется ногами отряда. Порядок важен: сдвиг → сопротивление
|
||||
→ жёсткий предел → `ResolveCircle` по стенам.
|
||||
|
||||
### Равновесие поводка, или почему отряд идёт с боем
|
||||
|
||||
Диск быстрее отряда, поэтому при зажатом направлении дистанция растёт, пока
|
||||
сопротивление поводка не сравняет скорости. Это равновесие — рабочий режим игры,
|
||||
и считать его надо явно:
|
||||
|
||||
```
|
||||
pull = (dist - anchorLeash*urgeStartFrac) / (anchorLeashMax - anchorLeash*urgeStartFrac)
|
||||
speed = lerp(moveSpeed, moveSpeedFast, urgency) отряд
|
||||
anchor = anchorSpeed * max(0.08, 1 - leashDrag*over) диск
|
||||
```
|
||||
|
||||
На текущих числах равновесие садится на `dist ≈ 3.4`, то есть `urgency ≈ 0.55`
|
||||
и скорость отряда ≈ 2.8 юнита/с — **ниже порога `sprintAt` (0.85)**. Значит,
|
||||
удерживая направление, игрок ведёт отряд быстрым шагом, и отряд при этом
|
||||
**продолжает работать по целям**: и стрелки, и клинки.
|
||||
|
||||
Спринт остаётся отдельным решением: либо `Shift` (прямой приказ), либо
|
||||
ситуация, когда курсор реально уехал. И только в спринте огонь прекращается.
|
||||
|
||||
Раньше порог стоял на 0.62, равновесие было выше него, и любое удержание клавиши
|
||||
означало вечный бег без стрельбы. Это и ощущалось как «милишники не работают».
|
||||
|
||||
## 6. Строй
|
||||
|
||||
Три пресета, локальные координаты (`x` = вперёд, `y` = вправо), множатся на
|
||||
`formationSpacing` и поворачиваются по направлению движения отряда:
|
||||
|
||||
- **клин**: вершина впереди, двое по бокам, двое сзади шире;
|
||||
- **линия**: пятеро в ряд поперёк движения;
|
||||
- **кольцо**: пятеро по окружности радиуса 1.5 × spacing.
|
||||
|
||||
В `HOLDING` слоты не используются — агент идёт к `postPos` от решателя.
|
||||
|
||||
Первые `meleeCount` слотов держат клинок (по умолчанию 2). В клине это остриё и
|
||||
левое крыло — те, кто и так упирается в противника первым. Боец ближнего боя
|
||||
идёт к `postPos` в **обоих** состояниях отряда: иначе клинок был бы полезен
|
||||
только на стоянке, а нужен он как раз в движении.
|
||||
|
||||
## 7. Огонь
|
||||
|
||||
`Game::FireWeapons`. Выстрел происходит, только если выполнено **всё**:
|
||||
|
||||
1. есть цель и чистая линия (`hasLane`);
|
||||
2. истёк `cooldown` (0.35 с);
|
||||
3. ствол доведён до цели (±5°) — иначе выстрел выглядел бы враньём;
|
||||
4. `LaneClearFrom` подтверждает линию по позициям этого шага (повторно, «никаких исключений»);
|
||||
5. `AllyInterceptsShot` — за время полёта пули свой не окажется на её пути.
|
||||
|
||||
Пункт 5 — добавление к спеку, вынужденное тем, что 6.3 проверяет линию в момент
|
||||
выстрела, а 6.9 делает пулю настоящим снарядом с временем полёта до 0.4 с.
|
||||
Обоснование и замеры — [08-decisions.md](08-decisions.md).
|
||||
|
||||
## 8. Пули
|
||||
|
||||
`friendlyFire = true` всегда: пуля сталкивается со стенами, мишенями и **со всеми
|
||||
капсулами, включая союзников** (кроме самого стрелка). За шаг ищется самое раннее
|
||||
столкновение вдоль пройденного отрезка, чтобы стена перед союзником срабатывала первой.
|
||||
|
||||
Пуля живёт ровно столько, чтобы пройти **проверенный** отрезок (дуло → цель, плюс
|
||||
радиус мишени). Дальше она не летит: за целью пространство никто не проверял, и
|
||||
если цель успела умереть от чужой пули, снаряд ушёл бы в непроверенную зону.
|
||||
|
||||
Счётчик попаданий по своим и журнал событий (время, стрелок, жертва, место,
|
||||
состояние жертвы) — в [06-debug-and-tuning.md](06-debug-and-tuning.md).
|
||||
|
||||
## 9. Ручное прицеливание
|
||||
|
||||
`Space` включает режим, `←`/`→` крутят вектор прицела от якоря, `Tab` циклит
|
||||
цели в конусе ±20°, отсортированные по дальности от якоря. Все агенты
|
||||
приоритезируют назначенную цель.
|
||||
|
||||
Правила 1–8 при этом **не меняются вообще**. Ручной режим никогда не позволяет
|
||||
стрелять сквозь своих — тот же `LaneClearFrom` на том же спуске.
|
||||
150
docs/06-debug-and-tuning.md
Normal file
150
docs/06-debug-and-tuning.md
Normal file
@ -0,0 +1,150 @@
|
||||
# 06 — Оверлей, метрики, тюнинг
|
||||
|
||||
## Оверлей
|
||||
|
||||
[src/debug/overlay.cpp](../src/debug/overlay.cpp). Рисуется примитивами raylib **поверх**
|
||||
уже отмасштабированного кадра. Это единственное исключение из «вся сцена софтверная»,
|
||||
и оно dev-only: `F1` его убирает.
|
||||
|
||||
| Клавиша | Что показывает |
|
||||
|---|---|
|
||||
| `F1` | оверлей целиком |
|
||||
| `F2` | линии огня |
|
||||
| `F3` | кандидаты последнего прогона решателя |
|
||||
| `F4` | метрики |
|
||||
| `F5` | выключить хоррор-слой целиком (свет, туман, пост) |
|
||||
| `F6` | показать диски обзора |
|
||||
|
||||
Всегда (при включённом `F1`): якорь отряда, **два** круга когезии (синий —
|
||||
стрелков, оранжевый — клинков), слоты строя, подпись над каждым агентом
|
||||
`индекс, класс (R/M), состояние (A/H), id цели` и у клинка фаза удара
|
||||
(`wind` / `HIT` / `rec`), выбранная огневая позиция `postPos` кружком.
|
||||
|
||||
**Сектор клинка**: кольцо досягаемости и две границы дуги, цвет по фазе —
|
||||
жёлто-оранжевый на замахе, белый на ударе, серый на восстановлении. В покое цвет
|
||||
говорит, почему не бьём: жёлтый — свой в дуге, синий — просто ждёт.
|
||||
|
||||
**Цвета линий огня (`F2`)**:
|
||||
|
||||
| Цвет | Значение |
|
||||
|---|---|
|
||||
| зелёный | линия чиста, агент стреляет |
|
||||
| жёлтый | цель есть, линию перекрыл свой |
|
||||
| красный | линию перекрыла стена |
|
||||
|
||||
**Кандидаты (`F3`)**: точками, цвет по оценке (синий низкая → белый высокая),
|
||||
отбракованные — тусклым красным, выбранный обведён белым кругом.
|
||||
Показывается последний прогон решателя, поэтому у агентов, у которых линия есть,
|
||||
там пусто — решатель для них не запускался, так и задумано.
|
||||
|
||||
В ручном режиме дополнительно рисуется вектор прицела, границы конуса ±20°
|
||||
и обводка назначенной цели.
|
||||
|
||||
## Метрики (`F4`)
|
||||
|
||||
[src/debug/metrics.h](../src/debug/metrics.h).
|
||||
|
||||
| Метрика | Как считается | Смысл |
|
||||
|---|---|---|
|
||||
| **FRIENDLY_HITS** | накопительный счётчик попаданий пуль по своим | главная метрика корректности, должна быть 0 |
|
||||
| **FIRE UPTIME** | доля шагов с валидной линией за окно 10 с; среднее — **только по стрелкам** | насколько отряд «в деле» |
|
||||
| **MELEE reach** | доля шагов с целью в зоне удара, среднее по клинкам | то же самое для ближнего боя |
|
||||
| `swings / hits / aborted` | взмахов начато, попаданий лезвием, взмахов оборвано своим в дуге | `aborted` — видимая цена скученности, не баг |
|
||||
| **SETTLE TIME** | от перехода в `HOLDING` до момента, когда ≥4 агента «в бою» | как быстро расходится веером |
|
||||
| fps, `sim ms/frame` | время симуляции за кадр | бюджет 16.6 мс |
|
||||
|
||||
«В бою» для стрелка — валидная линия, для клинка — выбранная цель. Смешивать
|
||||
доли времени двух разных занятий в одно среднее бессмысленно, поэтому
|
||||
`FIRE UPTIME` и `MELEE reach` считаются раздельно: у клинка линии огня не бывает
|
||||
по определению, и его ноль занижал бы метрику стрельбы.
|
||||
|
||||
Реализация `FIRE UPTIME` — кольцевой буфер на 600 сэмплов на агента с
|
||||
инкрементальными счётчиками, пересчёт O(1) за шаг.
|
||||
|
||||
**Журнал попаданий по своим**: каждое попадание пишется в `Metrics::hitLog`
|
||||
(время, стрелок, жертва, координаты, состояние жертвы). Без него `FRIENDLY_HITS != 0`
|
||||
нечем объяснить — именно журнал показал, что жертва всегда была `ADVANCING`,
|
||||
и вывел на настоящую причину (см. [08-decisions.md](08-decisions.md)).
|
||||
|
||||
### Как читать цифры
|
||||
|
||||
- `FIRE UPTIME` считает «нет живой цели в радиусе» как простой. Поэтому на
|
||||
переходах между комнатами и после зачистки кучки метрика честно падает — это не
|
||||
дефект ИИ. Критерий приёмки 11.8 относится к **стоячему бою рядом с мишенями**.
|
||||
- `SETTLE TIME` по той же причине растёт, если отряд остановился там, где стрелять
|
||||
не по чему. Сценарий `--accept` меряет его в правильной обстановке.
|
||||
|
||||
## Runtime-тюнинг
|
||||
|
||||
Все числа живут в [src/tuning.h](../src/tuning.h) в структуре `Tuning`.
|
||||
Магических констант по коду нет — если нужно новое число, оно добавляется туда.
|
||||
|
||||
Часть параметров вынесена в таблицу `TUNABLES` и крутится на ходу:
|
||||
|
||||
- `[` / `]` — выбрать параметр, текущее имя и значение видно в оверлее;
|
||||
- `-` / `=` — изменить с шагом параметра (с зажатием работает автоповтор).
|
||||
|
||||
Те же параметры можно задать из командной строки без пересборки:
|
||||
|
||||
```bash
|
||||
squad_proto.exe --headless 300 --set safetyMargin=0.25 --set laneReserve=2.0
|
||||
```
|
||||
|
||||
Список параметров смотри прямо в `TUNABLES` — там имя, поле, шаг и границы.
|
||||
|
||||
Самые содержательные для ощущения:
|
||||
|
||||
| Параметр | На что влияет |
|
||||
|---|---|
|
||||
| `safetyMargin` | запас к «телу в линии»; шире → реже стреляют, но безопаснее |
|
||||
| `laneReserve` | во сколько раз резерв линии шире порога блокировки; отвечает за «текучесть» |
|
||||
| `wLaneAvoid` | насколько нагло агент выдавливается из чужого сектора |
|
||||
| `hysteresis` | насколько лучше должна быть новая позиция, чтобы агент переехал |
|
||||
| `commitTime` | сколько держится решение; меньше → живее и дёрганее |
|
||||
| `sDistCost` / `sCohesionCost` | готовность отбегать далеко ради выстрела |
|
||||
| `postLeash` | жёсткая граница огневой позиции от диска; ниже — отряд плотнее |
|
||||
| `anchorSpeed` / `anchorLeash` | отзывчивость диска и насколько он может оторваться от отряда |
|
||||
|
||||
Ближний бой (подробности — [11-melee.md](11-melee.md)):
|
||||
|
||||
| Параметр | На что влияет |
|
||||
|---|---|
|
||||
| `meleeReach` / `meleeArc` | размер сектора удара; шире → чаще задевает нескольких, но и своих |
|
||||
| `meleeWindup` / `meleeStrike` / `meleeRecover` | ритм удара: читаемость замаха против скорострельности |
|
||||
| `meleeInterval` / `meleeDamage` | DPS клинка |
|
||||
| `meleeLunge` / `meleeKnock` | вес удара: рывок бойца и отдача мишени |
|
||||
| `meleeCohesion` | СВОЙ, больший круг клинка вокруг диска |
|
||||
| `meleeAllyMargin` / `meleePredict` | строгость дисциплины сектора; выше → меньше оборванных взмахов и меньше взмахов вообще |
|
||||
| `meleeReserve` | насколько широко клинок отжимает своих со своей дуги |
|
||||
| `meleeShake` | отдача камеры на попадание лезвием |
|
||||
|
||||
Хоррор-часть (подробности — [10-atmosphere.md](10-atmosphere.md)):
|
||||
|
||||
| Параметр | На что влияет |
|
||||
|---|---|
|
||||
| `sightRadius` / `sightCone` | как далеко и как широко видит отряд |
|
||||
| `sightPersonal` | круговой ореол вокруг агента, спасает от клаустрофобии |
|
||||
| `fogMemory` | насколько тускло помнится уже увиденное |
|
||||
| `fogReveal` | при каком свете показывается тело мишени |
|
||||
| `lightGamma` / `lightGain` | форма затухания света; **перепекают таблицу рампы** |
|
||||
| `flashPower` | насколько вспышка выстрела освещает окружение |
|
||||
| `fxIntensity` / `tracerLength` | яркость и длина трассеров |
|
||||
| `grainTense` / `vigDepthTense` | зерно и виньетка на пике напряжения |
|
||||
| `dreadRadius` | с какой дистанции мишень начинает пугать |
|
||||
| `shakePerShot` | отдача камеры |
|
||||
|
||||
`F5` — аварийный тумблер: мгновенно возвращает прежний кадр без света и тумана.
|
||||
Полезен, когда непонятно, баг это или просто темно.
|
||||
|
||||
## Dev-тумблеры ядра
|
||||
|
||||
`--no-solver` и `--no-laneavoid` выключают решатель и расступание. Это A/B-проверка
|
||||
ценности ядра, цифры за 60 с на одном маршруте:
|
||||
|
||||
| Конфигурация | FRIENDLY_HITS | FIRE UPTIME |
|
||||
|---|---|---|
|
||||
| ядро выключено | 1 | 13.6 % |
|
||||
| ядро включено | 0 | 88 % (на стоянке у мишеней) |
|
||||
|
||||
`sim`/`render` в оверлее показывают время шага симуляции и время кадра рендера
|
||||
раздельно: 2 мкс против 1.4–3.5 мс при бюджете 16.6 мс.
|
||||
54
docs/07-milestones.md
Normal file
54
docs/07-milestones.md
Normal file
@ -0,0 +1,54 @@
|
||||
# 07 — Журнал вех
|
||||
|
||||
Порядок из раздела 10 спека: после каждой вехи сборка и запуск, потом следующая.
|
||||
|
||||
| Веха | Что сделано | Чем проверено |
|
||||
|---|---|---|
|
||||
| **M0** | CMake + raylib через FetchContent, фреймбуфер 480×270, ×3 апскейл, fixed timestep, изометрическая сетка | сборка MSVC x64, окно открывается, текстура 480×270 RGBA8 создаётся |
|
||||
| **M1** | тайлмап 48×48, генерация уровня, процедурные спрайты пола и стен, камера за якорем, сортировка по глубине | скриншот: ромбы пола шахматкой, кубы стен перекрывают друг друга правильно |
|
||||
| **M2** | 5 агентов, движение якоря в экранных осях, три строя, separation, коллизии со стенами | скриншот: пятеро в клину, цвета различимы, стены не проходятся |
|
||||
| **M3** | мишени с респавном, DDA-рейкаст, `LaneClear`, авто-огонь, пули-снаряды, дружественный урон, `FRIENDLY_HITS` | `--headless 60 --no-solver --no-laneavoid`: 1 попадание по своим, uptime 13.6 % — то есть проблема, которую должно решать ядро, воспроизводится |
|
||||
| **M4** | решатель огневых позиций, гистерезис и коммит, реестр линий и расступание | то же 60 с с включённым ядром: 0 попаданий, uptime 88 % на стоянке у мишеней |
|
||||
| **M5** | ручное прицеливание, конус ±20°, назначенная цель, `Tab` | сценарий `11.7` в `--accept`: 0 попаданий по своим при активной назначенной цели |
|
||||
| **M6** | оверлей 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).
|
||||
327
docs/08-decisions.md
Normal file
327
docs/08-decisions.md
Normal file
@ -0,0 +1,327 @@
|
||||
# 08 — Решения и отклонения от спека
|
||||
|
||||
Спек в разделе 12 требует: раздел 6 реализуется как написано, а если считаешь,
|
||||
что где-то ошибка — сначала скажи, потом делай. Здесь перечислено **всё**, что
|
||||
отличается от буквы спека, с причиной и замерами.
|
||||
|
||||
Отклонения разделены на две группы: содержательные (меняют поведение) и
|
||||
структурные (не меняют).
|
||||
|
||||
---
|
||||
|
||||
## A. Содержательные
|
||||
|
||||
### A1. Пуля не летит дальше проверенного отрезка
|
||||
|
||||
**Спек**: 6.9 — снаряд со скоростью 22 юнита/с.
|
||||
**Сделано**: время жизни пули = длина отрезка «дуло → цель» / скорость,
|
||||
а не `дальность оружия / скорость`.
|
||||
|
||||
**Почему**. `LaneClear` проверяет отрезок до цели. Если цель успевает умереть от
|
||||
чужой пули, снаряд летит **дальше** — в пространство, которое никто не проверял.
|
||||
Ровно так возникало последнее упорное попадание по своим: агент стоял внутри
|
||||
кучки мишеней, пуля прошла место умершей мишени насквозь и нашла его.
|
||||
|
||||
**Замер**: до фикса — 1 попадание за 300 с, стабильно воспроизводимое
|
||||
(`t=94.49, #1 -> #2 @ (30.6,21.7)`); после — 0 за 300 с.
|
||||
|
||||
### A2. Спуск учитывает время полёта пули (`AllyInterceptsShot`)
|
||||
|
||||
**Спек**: 6.3 проверяет линию в момент выстрела.
|
||||
**Сделано**: дополнительно проверяется сближение пули и союзника как двух
|
||||
движущихся точек на всём времени полёта, с радиусом безопасности, растущим со
|
||||
временем (`shotPredictSlack` — запас на то, что союзник может и повернуть).
|
||||
|
||||
**Почему**. 6.3 даёт мгновенный снимок, а 6.9 делает пулю реальным снарядом:
|
||||
до 0.4 с полёта, за которые союзник проходит больше метра. Союзник, честно
|
||||
чистый в момент нажатия, спокойно входит в коридор уже после выстрела.
|
||||
Реактивное расступание (6.6) этот случай не закрывает по определению — оно
|
||||
срабатывает, только когда тело уже в линии.
|
||||
|
||||
Само правило 6.3 при этом **не ослаблено**: это дополнительное условие спуска,
|
||||
а не замена. `LaneClear` продолжает использоваться и в целеуказании, и в оценке
|
||||
кандидатов, и в реестре линий — в том виде, как написано в спеке.
|
||||
|
||||
**Замер**: журнал попаданий показывал, что жертва **всегда** была `ADVANCING` —
|
||||
то есть именно вбегала под летящую пулю.
|
||||
|
||||
### A3. В реестр линий попадают линии, перекрытые своим
|
||||
|
||||
**Спек**: 6.6 — «список активных линий огня — сегментов от **стреляющих** агентов».
|
||||
**Сделано**: линия регистрируется и тогда, когда у агента есть цель в радиусе,
|
||||
стена не мешает, а мешает **только свой** (`Agent::laneIntent`).
|
||||
|
||||
**Почему**. При буквальном прочтении механика самоотменяется: стоит кому-то
|
||||
влезть в линию — стрелок теряет линию, перестаёт быть «стреляющим», отрезок
|
||||
исчезает из реестра, и перекрывшего **ничто не выталкивает**. Сектор обстрела не
|
||||
освобождается никогда, а обещание 6.6 («отряд сам расплывается, освобождая
|
||||
сектора обстрела») и критерий 11.4 становятся недостижимыми.
|
||||
|
||||
**Замер**: `FIRE UPTIME` на маршруте вырос с 33 % до 49 %, худший uptime на
|
||||
стоянке — с 38 % до 51 %.
|
||||
|
||||
### A4. Резерв линии шире порога блокировки (`laneReserveScale = 1.8`)
|
||||
|
||||
**Спек**: 6.6 — импульс, если `dist < bodyRadius + laneHalfWidth + SAFETY_MARGIN`.
|
||||
**Сделано**: импульс расступания начинается на `laneReserveScale` шире этого
|
||||
порога. Сам порог блокировки в 6.3 не тронут.
|
||||
|
||||
**Почему**. Раздел называется «**Резервирование** линий». Если отжимать агента
|
||||
ровно с той границы, за которой он уже блокирует выстрел, — это не резерв, а
|
||||
реакция постфактум: агент успевает войти в сектор, где летит пуля.
|
||||
|
||||
**Замер** (300 с, с A1 и A2):
|
||||
|
||||
| `laneReserve` | FRIENDLY_HITS | худший uptime на стоянке |
|
||||
|---|---|---|
|
||||
| 1.0 (буква спека) | 1 | 42.5 % |
|
||||
| 1.4 | 0 | 50.0 % |
|
||||
| **1.8** (принято) | 0 | 50.4 % |
|
||||
|
||||
### A5. Касательная составляющая обхода стен (`wallSlide`)
|
||||
|
||||
**Спек**: 6.7, слой 4 — «wallAvoidance + скольжение вдоль стен при коллизии».
|
||||
**Сделано**: помимо скольжения при коллизии, `WallAvoidForce` добавляет
|
||||
касательную силу в сторону цели, когда стена стоит между агентом и целью.
|
||||
|
||||
**Почему**. Чисто отталкивающее избегание даёт классический локальный минимум:
|
||||
seek тянет вперёд, отталкивание держит — агент залипает в «тени» препятствия.
|
||||
В прогонах это ловилось буквально: агент оставался в северной комнате
|
||||
(`y=12`), когда отряд уже стоял на арене (`y=26`).
|
||||
|
||||
### A6. Агент без целей в радиусе возвращается в строй
|
||||
|
||||
**Спек**: случай не описан. 6.5 запускает решатель, когда линии нет, но не
|
||||
говорит, что делать, если стрелять вообще не по чему.
|
||||
**Сделано**: если решатель не нашёл ни одной цели в пределах
|
||||
`weaponRange + ring3`, `postPos` = слот строя.
|
||||
|
||||
**Почему**. Иначе отставший агент стоит на месте вечно: в `HOLDING` он идёт
|
||||
только к `postPos`, а `postPos` равен его же позиции.
|
||||
|
||||
### A7. Дуло считается в сторону цели, а не по `facing`
|
||||
|
||||
**Спек**: 6.3 — «`muzzle()` — точка в 0.3 юнита от центра агента по `facing`».
|
||||
**Сделано**: для проверки линии и для спавна пули дуло считается по направлению
|
||||
на цель; выстрел разрешён только когда ствол доведён до цели с точностью ±5°
|
||||
(`aimToleranceDeg`).
|
||||
|
||||
**Почему**. `facing` доворачивается плавно и на шаг отстаёт от направления на
|
||||
цель. Если проверять линию по одному направлению, а стрелять по другому, пуля
|
||||
летит не по проверенному отрезку — то есть гарантия «не сквозь своих» перестаёт
|
||||
быть гарантией. Сейчас проверенный отрезок и траектория пули — буквально один и
|
||||
тот же отрезок.
|
||||
|
||||
### A8. Пуля не бьёт собственного стрелка
|
||||
|
||||
**Спек**: 6.9 — «коллизия со **всеми** капсулами, включая союзников».
|
||||
**Сделано**: стрелок исключён из коллизий своей пули.
|
||||
|
||||
**Почему**. Дуло вынесено на 0.3 юнита при радиусе тела 0.28 — пуля стартует
|
||||
почти вплотную к своему же телу. Самопопадание было бы вырожденным шумом в
|
||||
`FRIENDLY_HITS`, главной метрике корректности. Все **остальные** капсулы,
|
||||
включая союзников, проверяются как написано.
|
||||
|
||||
### A9. Северная кучка мишеней стоит напротив прохода
|
||||
|
||||
**Спек**: 4 — 12 мишеней кучками, конкретные места на усмотрение реализации.
|
||||
**Сделано**: кучка из 3 мишеней в северной комнате поставлена напротив прохода
|
||||
`x=30..31`, так что из коридора до неё есть чистая линия.
|
||||
|
||||
**Почему**. Спек называет узкий проход важным тест-кейсом, а критерий 11.6
|
||||
требует, чтобы в проходе отряд вытягивался в колонну и стрелял только передний.
|
||||
Без цели, простреливаемой сквозь проход, проверять нечего.
|
||||
|
||||
### A10. Вторичные цели в `HOLDING` при ручном прицеливании
|
||||
|
||||
**Спек**: 6.8 — флаг `freeFireSecondary` описан только для `ADVANCING`.
|
||||
**Сделано**: буквально — в `HOLDING` агент без линии до назначенной цели молчит
|
||||
и перестраивается решателем, по вторичным целям не стреляет.
|
||||
|
||||
**Почему**. Иначе агент нашёл бы вторичную цель, получил бы `hasLane`, и решатель
|
||||
для него никогда бы не запустился — то есть требование «в `HOLDING`
|
||||
перестраивается через решатель» не выполнялось бы.
|
||||
|
||||
### A11. Туман войны и освещение вопреки разделу 12
|
||||
|
||||
**Спек**: 12 — «не добавлять освещение, туман войны».
|
||||
**Сделано**: добавлено и то, и другое.
|
||||
|
||||
**Почему**. Заказчик изменил требования: прототип разворачивается в хоррор, где
|
||||
ограниченный обзор и темнота — не украшение, а основа подачи. Правила стрельбы
|
||||
(раздел 6) при этом не тронуты ни одной строкой, критерии приёмки остались
|
||||
зелёными. Устройство слоя — [10-atmosphere.md](10-atmosphere.md).
|
||||
|
||||
### A12. Конус зрения не влияет на ИИ
|
||||
|
||||
**Сделано**: у агента есть конус обзора для КАРТИНКИ, но выбор целей и стрельба
|
||||
по-прежнему работают по LOS и дальности оружия.
|
||||
|
||||
**Почему**. Если завязать целеуказание на видимость, меняются все критерии
|
||||
приёмки, а отряд начинает «слепнуть» от поворота ствола — при том что `LaneClear`
|
||||
и так требует чистой линии, то есть отсутствия стены. Чтобы стрельба в темноту
|
||||
не выглядела абсурдом, мишень показывается, если освещена **или** если по ней уже
|
||||
работает кто-то из отряда.
|
||||
|
||||
### A13. Shift — рывок, а не медленный шаг
|
||||
|
||||
**Спек**: 5 — «Shift (удерж.) — медленный шаг».
|
||||
**Сделано**: базовая скорость снижена с 3.2 до 2.0, а Shift даёт рывок 3.2.
|
||||
|
||||
**Почему**. Заказчик просил «менее казуальное управление, помедленнее». Медленный
|
||||
шаг при уже медленной базе — мёртвая клавиша. Инверсия сохраняет обе скорости из
|
||||
спека (2.0 и 3.2), но делает осторожность режимом по умолчанию, а спешку —
|
||||
осознанным решением игрока. Побочный эффект: проверка спуска на время полёта пули
|
||||
обязана считать по МАКСИМАЛЬНОЙ скорости (`MaxAgentSpeed()`), иначе рывок союзника
|
||||
не учитывается и возвращаются попадания по своим.
|
||||
|
||||
### A14. Дальность оружия 9.0 -> 7.2
|
||||
|
||||
**Сделано**: минус 20 % по требованию заказчика; вместе с ней отмасштабированы
|
||||
`minEngageDist` (2.0 → 1.6) и кольца решателя (0.8/1.6/2.4 → 0.7/1.4/2.1),
|
||||
а старт отряда сдвинут в (26.5, 26.5) ближе к кучке мишеней.
|
||||
|
||||
**Замер**: без сдвига старта и масштабирования колец сокращение дальности само по
|
||||
себе давало 1 попадание по своим за 300 с и просадку uptime — агенты сбивались в
|
||||
кучу у цели. С пакетом целиком: 0 попаданий, uptime в стоячем бою 100 %.
|
||||
|
||||
### A15. `candMinAlly` 0.55 -> 0.60
|
||||
|
||||
Порог «кандидат слишком близко к союзнику» был **меньше диаметра тела**
|
||||
(2 × 0.28 = 0.56), то есть допускал позиции, где агенты физически пересекаются.
|
||||
|
||||
### A16. Клинок союзникам урона не наносит вообще
|
||||
|
||||
**Спек**: ближнего боя не описывает.
|
||||
**Сделано**: сектор удара повторяет правило 6.3 в угловой форме — свой в
|
||||
секторе — не бить. Но урон применяется **только к мишеням**: даже влезший под
|
||||
лезвие союзник не получает ничего, взмах просто обрывается.
|
||||
|
||||
**Почему**. Соблазн был сделать «сильную» версию: клинок бьёт всех, а от
|
||||
попаданий по своим спасает дисциплина сектора. Но у клинка нет времени полёта,
|
||||
за которое можно передумать: союзник входит в дугу за 1/60 с уже после того, как
|
||||
взмах начат, и в этот момент отменять нечего. «Сильная» версия означала бы
|
||||
`FRIENDLY_HITS > 0` в любой плотной свалке, то есть провал главного критерия
|
||||
прототипа ради механики, которой в спеке нет.
|
||||
|
||||
Обрыв взмаха даёт то же самое ощущение (боец отдёргивает клинок, серая вспышка,
|
||||
`MELEE_ABORT` в метриках) и при этом не может сломать критерий 11.2.
|
||||
|
||||
**Замер**: 81 взмах за 30 с в плотной свалке, `FRIENDLY_HITS = 0`,
|
||||
16 взмахов оборвано.
|
||||
|
||||
### A17. Старт замаха смотрит на союзника вперёд
|
||||
|
||||
**Сделано**: `MeleeArcClear` проверяет союзника в двух точках — где он сейчас и
|
||||
где будет через `windup + strike`. Ровно та же поправка, что A2 для пули.
|
||||
|
||||
**Почему**. Взмах живёт 0.30 с, за которые союзник проходит полметра. Проверять
|
||||
только текущее положение — значит начинать удары, которые всё равно придётся
|
||||
обрывать.
|
||||
|
||||
**Замер** (сценарий M7, 30 с): без поправки и без резерва дуги — 21 оборванный
|
||||
взмах из 77; с поправкой и резервом — 16 из 81, попаданий 81 против 81, убийств
|
||||
23 против 23. То есть взмахов стало больше при том же результате.
|
||||
|
||||
### A18. Резерв в реестре линий получил явную полуширину
|
||||
|
||||
**Сделано**: у `FireLane` появилось поле `reserve`. Линия огня по-прежнему берёт
|
||||
ширину из правила 6.3, клинок задаёт свою — долю бокового размаха сектора
|
||||
(`meleeReserveFrac` 0.55).
|
||||
|
||||
**Почему**. Клинок резервирует отрезок вдоль оси удара, а поражает сектор ±50°.
|
||||
Союзник, отжатый строго перпендикулярно оси, попадал ровно во фланг дуги — то
|
||||
есть механизм расступания работал против себя.
|
||||
|
||||
**Замер**: `meleeReserve` 0 → 0.55 дало 16 оборванных взмахов вместо 21 при
|
||||
большем числе взмахов, а худший `FIRE UPTIME` на стоянке маршрута — 23.3 %
|
||||
вместо 0 % (стрелки перестали стоять в дугах и молчать).
|
||||
|
||||
### A19. Жёсткая граница огневой позиции (`postLeash`)
|
||||
|
||||
**Спек**: 6.5 — цена за удаление от якоря.
|
||||
**Сделано**: дополнительно кандидат дальше `cohesionRadius + postLeash` от якоря
|
||||
отбраковывается совсем.
|
||||
|
||||
**Почему**. Цена — мягкая: линия огня стоит 100 очков, а уход на 1.5 юнита за
|
||||
круг — 36. Решатель всегда выбирал линию, и отряд расползался.
|
||||
|
||||
**Замер**: максимум удаления стрелка от диска 3.65 → 2.81 юнита. При этом
|
||||
`FIRE UPTIME` на 180-секундном маршруте не упал, а вырос: на стоянках у мишеней
|
||||
82.9 / 93.2 / 90.7 / 86.0 % — плотный отряд перекрывает друг другу линии реже,
|
||||
чем расползшийся.
|
||||
|
||||
### A20. Мишени — тела, и они получают отдачу
|
||||
|
||||
**Сделано**: слой движения `TargetPushForce` (мишень расталкивает агентов) и
|
||||
импульс от удара с возвратом на исходное место (`Target::home`).
|
||||
|
||||
**Почему**. Без расталкивания боец стоит **внутри** того, кого рубит, и удар не
|
||||
читается вообще. Без отдачи попадание в упор визуально не отличается от промаха.
|
||||
Возврат на `home` обязателен: расстановка уровня захардкожена, и уползание мишеней
|
||||
поломало бы все замеры разом. Респавн дополнительно ставит `pos = home`.
|
||||
|
||||
### A21. Критерий 11.6 считает только выстрелы вперёд
|
||||
|
||||
**Было**: тест требовал, чтобы в коридоре передний агент стрелял минимум вдвое
|
||||
чаще хвостовых, и считал ВСЕ выстрелы внутри прохода.
|
||||
|
||||
**Стало**: считаются только выстрелы **по ходу движения**. Выстрелы назад
|
||||
выводятся в отчёт отдельно.
|
||||
|
||||
**Почему**. Диагностика `DIAG` показала, что почти все «нарушения» — это
|
||||
хвостовой агент, стреляющий назад, в оставленную позади комнату, по совершенно
|
||||
другой цели. Впереди по такой линии никого из отряда нет по построению, и к
|
||||
правилу «не стрелять сквозь своего» это отношения не имеет. Порог ловил не
|
||||
поведение, а геометрию расстановки мишеней.
|
||||
|
||||
**Замер**: на прежней базе тюнинга новый счёт даёт 4/0 (было 12/4), на текущей —
|
||||
5/0. То есть критерий стал строже, а не мягче: вперёд хвост не стреляет вообще.
|
||||
|
||||
---
|
||||
|
||||
## B. Структурные (поведение не меняют)
|
||||
|
||||
### B1. Дополнительные файлы
|
||||
|
||||
Спек перечисляет структуру проекта; добавлены четыре файла:
|
||||
|
||||
| Файл | Зачем |
|
||||
|---|---|
|
||||
| `game.h/.cpp` | склейка подсистем и порядок шага. Без него это переехало бы в `main.cpp`, а спек требует, чтобы там были только окно и цикл |
|
||||
| `render/scene.h/.cpp` | проход по полу и список сортировки; отдельно от `iso.h`, который остаётся чистой математикой |
|
||||
| `core/input.h` | `InputState` нужен и `main.cpp`, и симуляции, и headless-прогонам |
|
||||
| `debug/harness.h/.cpp` | прогоны `--headless` / `--accept` |
|
||||
| `sim/loadout.h` | класс оружия и его параметры; сюда приедут разные пушки и апгрейды |
|
||||
| `ai/melee.h/.cpp` | ближний бой; зеркало `firing_solver` для угловой геометрии |
|
||||
|
||||
### B2. У `Sprite` есть точка привязки
|
||||
|
||||
Спек: `Sprite { int w, h; std::vector<uint32_t> px; }`. Добавлены поля `ax, ay` —
|
||||
точка внутри спрайта, совмещаемая с проекцией мировой позиции. Иначе смещения
|
||||
пришлось бы размазывать по коду отрисовки для каждого типа спрайта.
|
||||
|
||||
### B3. Параметры оружия синхронизируются из `Tuning` каждый шаг
|
||||
|
||||
`Agent::weapon` существует как в спеке, но перед движением поля копируются из
|
||||
`g_tune` (`SyncLoadoutFromTuning`). Без этого runtime-подкрутка (раздел 8 спека)
|
||||
не влияла бы на уже созданных агентов. Состояние ближнего боя — фаза, таймеры,
|
||||
цель — при этом не трогается: копируются только параметры.
|
||||
|
||||
### B4. Dev-инструменты
|
||||
|
||||
`--headless`, `--accept`, `--probe`, `--set`, `--shot`, тумблеры `--no-solver` /
|
||||
`--no-laneavoid`. В спеке их нет; они не влияют на игровую сборку и нужны, чтобы
|
||||
критерии приёмки из раздела 11 проверялись цифрами, а не на глаз.
|
||||
|
||||
---
|
||||
|
||||
## Что осталось честно неидеальным
|
||||
|
||||
- `FIRE UPTIME` считает «нет живой цели в радиусе» простоем. На маршруте между
|
||||
комнатами это даёт низкие значения, которые к качеству ИИ отношения не имеют.
|
||||
- `SETTLE TIME` по той же причине растёт, если отряд остановился там, где стрелять
|
||||
не по чему; в общем прогоне попадаются значения до 5.7 с. В обстановке, к
|
||||
которой относится критерий 11.1 (мишени в радиусе), — 0.02 с.
|
||||
- Пути агентов чисто локальные, без поиска пути. Отряд проходит узкие коридоры,
|
||||
но лабиринт с тупиками не осилит — это вне рамок прототипа.
|
||||
107
docs/09-acceptance.md
Normal file
107
docs/09-acceptance.md
Normal file
@ -0,0 +1,107 @@
|
||||
# 09 — Критерии приёмки
|
||||
|
||||
Раздел 11 спека. Воспроизвести: `build/bin/squad_proto.exe --accept`.
|
||||
Сценарии — [src/debug/harness.cpp](../src/debug/harness.cpp), `RunAccept()`.
|
||||
|
||||
## Автоматическая проверка
|
||||
|
||||
Критерии 1, 6, 7, 8 описывают **правила стрельбы** и меряются на отряде из пяти
|
||||
стрелков — так, как их формулировал спек. Ближний бой проверяется отдельным
|
||||
блоком M7 и, главное, трёхминутным прогоном 11.2, где состав отряда обычный,
|
||||
боевой (2 клинка + 3 ствола).
|
||||
|
||||
| # | Критерий | Статус | Цифры последнего прогона |
|
||||
|---|---|---|---|
|
||||
| 1 | ≥4 из 5 агентов имеют линию за ≤1.5 с после остановки | **PASS** | settle = 0.32 с, линий 5/5 |
|
||||
| 2 | `FRIENDLY_HITS` = 0 за 3 минуты боя во всех режимах | **PASS** | 0 попаданий, смешанный отряд |
|
||||
| 4 | агент с чужой линии огня уходит за ~0.3 с | **PASS** | максимум 0.35 с на арене (0.98 с по всем сценариям) |
|
||||
| 5 | нет дёрганья: `postPos` не чаще раза в 0.6 с | **PASS** | 36 коммитов, 0 нарушений, минимальный интервал 0.75 с |
|
||||
| 6 | в проходе 2 тайла — колонна, огонь не идёт сквозь своих | **PASS** | до 5 агентов в створе, поперёк 3.09 / вдоль 6.08 тайла, вперёд передний/хвост 77/9, сквозь союзника 0 выстрелов из 247 |
|
||||
| 7 | ручной режим никогда не стреляет сквозь своих | **PASS** | 0 попаданий по своим за 30 с, назначенная цель 96 % времени |
|
||||
| 8 | `FIRE UPTIME` в стоячем бою на арене ≥ 80 % | **PASS** | 100 % |
|
||||
| 9 | стабильные 60 fps | **PASS** | симуляция 2.5 мкс + рендер 4.1 мс при бюджете 16.6 мс, fps 60 |
|
||||
|
||||
Ближний бой ([11-melee.md](11-melee.md)), сценарий 30 с у кучки мишеней:
|
||||
|
||||
| # | Критерий | Статус | Цифры |
|
||||
|---|---|---|---|
|
||||
| M7.1 | клинок доходит до цели и наносит урон | **PASS** | 55 взмахов, 52 попадания, 16 убийств, 14 оборвано своими; связка `slash 21 / backhand 19 / spin 15` |
|
||||
| M7.2 | ближний бой не задевает своих | **PASS** | `FRIENDLY_HITS = 0` при 55 взмахах |
|
||||
| M7.3 | у каждого класса свой круг вокруг диска | **PASS** | стрелки не дальше 3.49 от диска, клинки — 5.61 (лимиты 3.70 / 6.80) |
|
||||
| M7.4 | лезвие не достаёт за пределы своей дуги | **PASS** | превышение сектора 0.0° (лимит 12°) |
|
||||
|
||||
M7.4 появился после починки геометрии удара: угловой допуск `asin(r/dist)`
|
||||
вырождался на контактной дистанции в 90°, и боец рубил за спину — те самые
|
||||
«удары на 360 градусов». Теперь проверка идёт по отрезку лезвия, а критерий
|
||||
замеряет превышение разрешённого сектора и не даёт багу вернуться
|
||||
([11-melee.md](11-melee.md)).
|
||||
|
||||
M7.2 держится не тюнингом, а конструкцией: урон применяется только к мишеням, а
|
||||
влезший под лезвие союзник обрывает взмах ([08-decisions.md](08-decisions.md), A16).
|
||||
|
||||
Прогон 300 с (`--headless 300`), смешанный отряд: `FRIENDLY_HITS = 0`,
|
||||
1471 выстрел, 142 взмаха / 146 попаданий лезвием, **63 убийства**.
|
||||
Тот же маршрут чистым отрядом стрелков (`--melee 0`): 2100 выстрелов,
|
||||
45 убийств. Клинки заметно поднимают урон отряда, не ломая главного правила.
|
||||
|
||||
`FIRE UPTIME` на стоянках маршрута: 82.9 / 93.2 / 90.7 / 86.0 % там, где есть
|
||||
мишени, и 23.3 % на стоянке, где стрелять не по чему.
|
||||
|
||||
Тест 11.6 переписан ещё раз, и на этот раз по существу — с подсчёта «кто стрелял»
|
||||
на проверку инварианта.
|
||||
|
||||
Причина была в другом месте, чем казалось. Сценарий тащил курсор непрерывно,
|
||||
курсор обгонял отряд, натяжение поводка упиралось в упор, включался спринт — а в
|
||||
спринте отряд по замыслу не стреляет вовсе. Тест показывал «0 выстрелов вперёд» и
|
||||
падал, проверяя пустой коридор. После поднятия `sprintAt` до 0.85 удержание
|
||||
направления спринт больше не включает, и отряд идёт через проход **с боем** —
|
||||
как и должен.
|
||||
|
||||
Область замера тоже была неверной: проход глубиной три тайла, а бойцов пятеро,
|
||||
и колонна физически длиннее самого прохода — внутрь него целиком она не
|
||||
помещается никогда. Меряется теперь створ прохода с подходами по обе стороны,
|
||||
на всей проводке, а не только на стоянке.
|
||||
|
||||
Заодно выброшен критерий «передний стреляет чаще заднего». В проходе шириной два
|
||||
тайла у заднего есть законные диагональные линии мимо переднего, и требовать их
|
||||
отсутствия — значит требовать от отряда быть глупее, чем он есть. «Задние не
|
||||
стреляют» — это следствие правила «не сквозь своего», а не отдельное правило.
|
||||
Поэтому проверяется сам инвариант: берётся отрезок **реально сделанного**
|
||||
выстрела и независимой геометрией проверяется, не задел ли он тело союзника.
|
||||
|
||||
Результат: 0 из 247 выстрелов прошли сквозь своего, колонна 6.08 тайла вдоль
|
||||
против 3.09 поперёк, и передний всё равно ведёт огонь с перевесом 77 против 9 —
|
||||
теперь это наблюдение, а не условие прохождения.
|
||||
|
||||
## Проверяется глазами / по коду
|
||||
|
||||
| # | Критерий | Как подтверждено |
|
||||
|---|---|---|
|
||||
| 3 | в движении стреляют только те, у кого чистая линия | по построению: спуск требует `hasLane`; в оверлее `F2` видно, что часть агентов идёт с жёлтой линией и молчит |
|
||||
| 10 | вся сцена нарисована своим растеризатором | в `render/` нет ни одного raylib-примитива; raylib вызывается только в `ScreenBlitter` (`UpdateTexture` + `DrawTexturePro`) и в `debug/overlay.cpp`, который dev-only и снимается по `F1` |
|
||||
|
||||
Скриншот боевого кадра ([docs/combat.png](combat.png)): отряд в пятне света,
|
||||
у каждого своя зелёная линия, ни одна не проходит через своего, трассеры в полёте,
|
||||
дальние мишени видны только тлеющими глазами. `FRIENDLY_HITS 0`.
|
||||
|
||||
## Оговорки, которые стоит знать
|
||||
|
||||
- Критерий 1 и метрика `SETTLE TIME` осмысленны только когда в радиусе есть живые
|
||||
мишени. В общем маршруте попадаются значения до 5.7 с — это остановки там, где
|
||||
стрелять не по чему, а не медленное расхождение.
|
||||
- Критерий 8 меряется в стоячем бою рядом с кучкой. При остановке на краю
|
||||
дальности честные значения ниже: часть агентов физически не достаёт цель.
|
||||
После сокращения дальности до 7.2 худшая стоянка маршрута даёт ~30 % — это
|
||||
остановка там, где кучка уже вне радиуса, а не деградация решателя.
|
||||
- `FRIENDLY_HITS = 0` достигается связкой из четырёх вещей: резерв линий, включая
|
||||
перекрытые своим; резерв шире порога блокировки; учёт времени полёта на спуске;
|
||||
пуля не летит дальше проверенного отрезка. Отключение любой из них возвращает
|
||||
попадания по своим — цифры в [08-decisions.md](08-decisions.md).
|
||||
- `FIRE UPTIME` усредняется только по стрелкам, `MELEE reach` — только по клинкам.
|
||||
Смешивать доли времени двух разных занятий в одно среднее бессмысленно.
|
||||
- «Макс. время в чужой линии» на смешанном отряде доходит до 3.9 с против 1.3 с
|
||||
на чистых стрелках. Это боец с клинком, который стоит вплотную к мишени и тем
|
||||
самым перекрывает стрелку линию: расступиться он не может, не бросив свою
|
||||
работу. Правило при этом не нарушается — стрелок просто не стреляет и
|
||||
перерешивает позицию. Критерий 11.4 меряется на открытой арене чистым отрядом
|
||||
стрелков, где такой ситуации нет по построению.
|
||||
200
docs/10-atmosphere.md
Normal file
200
docs/10-atmosphere.md
Normal file
@ -0,0 +1,200 @@
|
||||
# 10 — Хоррор-слой: свет, туман войны, трассеры, напряжение
|
||||
|
||||
Весь этот слой — **render-only**. Симуляция о нём не знает: `RenderGame` принимает
|
||||
`const Game&`, поэтому ни свет, ни трассеры, ни тряска камеры физически не могут
|
||||
изменить состояние симуляции. Headless-прогоны (`--accept`, `--headless`) `View`
|
||||
не создают вообще, и цифры приёмки остаются цифрами симуляции.
|
||||
|
||||
> Раздел 12 исходного спека запрещал освещение и туман войны. Заказчик изменил
|
||||
> требования — теперь это ядро подачи. Правила стрельбы (раздел 6) не тронуты.
|
||||
|
||||
## Порядок кадра
|
||||
|
||||
```
|
||||
Clear(чёрный) ступень 0 рампы == цвет очистки
|
||||
-> свет и туман (Lighting::Update)
|
||||
-> пол: BlitShaded, тёмные тайлы пропускаются
|
||||
-> список сортировки: стены, мишени, агенты — тоже BlitShaded
|
||||
-> глаза мишеней (аддитивно)
|
||||
-> трассеры, вспышки дула, искры (аддитивно)
|
||||
-> ОДИН слитый пост-проход: виньетка, зерно, сердцебиение
|
||||
-> Present
|
||||
-> debug-оверлей (raylib, dev-only, F1)
|
||||
```
|
||||
|
||||
## Почему свет вплавлен в блит, а не сделан пост-проходом
|
||||
|
||||
Соблазн очевидный: пройти по 129600 пикселям, для каждого получить мировую точку
|
||||
через `iso::ScreenToWorld` и умножить на освещённость. Так делать нельзя.
|
||||
|
||||
Спрайт стены имеет привязку `ay = TILE_HALF_H + TILE_H = 24`, то есть верхняя
|
||||
грань поднята на 16 px над ромбом своего тайла. Обратная проекция этого пикселя
|
||||
даёт `ScreenToWorld(0, -16) = (-1, -1)` — **ровно соседний тайл на северо-запад**.
|
||||
Пост-проход осветил бы крыши стен чужой видимостью: чёрные крыши на освещённых
|
||||
стенах и светящийся ореол на тайл северо-западнее каждой стены. Чинится это
|
||||
только буфером высот на пиксель, то есть честным depth-буфером, которого в
|
||||
проекте нет и заводить его ради этого не стоит.
|
||||
|
||||
Поэтому свет модулирует цвет **на этапе блита спрайта**: спрайт целиком светится
|
||||
от своего тайла, и артефакт исчезает конструктивно, без единой проверки.
|
||||
|
||||
## Модель света
|
||||
|
||||
Одна скалярная величина на точку мира плюс **рампа** — таблица
|
||||
`g_shadeLut[32][3][256]`, переводящая уровень в цвет (подход COLORMAP из Doom).
|
||||
Цветокоррекция зашита в таблицу: в полумраке картинка уходит в холодный синий,
|
||||
на свету нейтральна, во вспышке выбивается в тёплый пересвет. На пиксель
|
||||
остаются три выборки из таблицы и ноль умножений.
|
||||
|
||||
```
|
||||
tile = min(1, max(память, видимость * sightBrightness) + вспышки)
|
||||
```
|
||||
|
||||
| Состояние | Значение | Как выглядит |
|
||||
|---|---|---|
|
||||
| никогда не видели | 0 | чистый чёрный, тайл вообще не рисуется |
|
||||
| видели раньше | `fogMemoryLevel` | тускло и холодно, сущностей нет |
|
||||
| видно сейчас | `sightBrightness` | нормальная яркость |
|
||||
| вспышка выстрела | выше | тёплый пересвет |
|
||||
|
||||
Диапазон выше `sightBrightness` намеренно зарезервирован под динамический свет:
|
||||
выстрел всегда «выбивает» кадр вверх и в тепло.
|
||||
|
||||
### Билинейка без швов
|
||||
|
||||
Узловая сетка 49×49 (`corner`), узел = среднее смежных тайлов. Патч света на
|
||||
тайл раскладывается в координатах спрайта без перекрёстного члена:
|
||||
|
||||
```
|
||||
Bu = c10-c00, Bv = c01-c00, K = c00-c10-c01+c11
|
||||
a = c00 - (Bu-Bv)/2 - K/4 b = (Bu-Bv+K)/TILE_W c = (Bu+Bv)/TILE_H
|
||||
e = -K/(TILE_W*TILE_W) f = K/(TILE_H*TILE_H)
|
||||
L(px,py) = a + b*px + c*py + e*px^2 + f*py^2
|
||||
```
|
||||
|
||||
Это **точная** билинейка по четырём вершинам ромба: подстановка (16,0), (32,8),
|
||||
(0,8), (16,16) даёт ровно c00, c10, c01, c11. Значит на общем ребре двух соседних
|
||||
тайлов значения совпадают и швов нет. По строке считается прямыми разностями —
|
||||
во внутреннем цикле два сложения на пиксель.
|
||||
|
||||
Блочность добивают четыре вещи: узловая сетка, точная билинейка, временное
|
||||
сглаживание (`fogFadeRate`) и упорядоченный дизеринг Байера 4×4 — 32 ступени
|
||||
рампы выглядят как ~200, полос не видно.
|
||||
|
||||
## Видимость
|
||||
|
||||
Для каждого агента перебираются тайлы в квадрате радиуса обзора; для каждого —
|
||||
затухание по дальности × max(конус взгляда, круговой «личный» ореол), затем
|
||||
проверка LOS уже существующим `Tilemap::RaycastBlocked`. Рейкаст делается
|
||||
последним: дальность и конус отсекают большинство тайлов даром.
|
||||
|
||||
Стену нельзя проверить лучом до её центра — DDA считает вход в саму стену
|
||||
перекрытием. Поэтому стены освещаются отдельным проходом: берут максимум от
|
||||
соседей-нестен, `WALL_LIGHT_PASSES = 3` раза с затуханием. Без этого разделитель
|
||||
`y=13..15` толщиной три тайла остаётся чёрным изнутри, и его южная грань висит
|
||||
в пустоте.
|
||||
|
||||
**Инвариант:** стена никогда не темнее самого яркого соседа-пола. Иначе тёмная
|
||||
стена будет пропущена как «ступень 0», и сквозь её силуэт проступит освещённый
|
||||
пол тайла (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` — показать диски обзора. Полезно, когда непонятно, баг это или темнота.
|
||||
222
docs/11-melee.md
Normal file
222
docs/11-melee.md
Normal file
@ -0,0 +1,222 @@
|
||||
# 11 — Ближний бой
|
||||
|
||||
Второй класс оружия. Файлы: [src/sim/loadout.h](../src/sim/loadout.h) (типы и
|
||||
профили), [src/ai/melee.cpp](../src/ai/melee.cpp) (цель, подход, дисциплина
|
||||
сектора), `Game::UpdateMelee` в [src/game.cpp](../src/game.cpp) (машина
|
||||
состояний и урон), [src/render/fx.cpp](../src/render/fx.cpp) (дуга).
|
||||
|
||||
## Главная мысль
|
||||
|
||||
Ближний бой — это **то же правило, что и стрельба, в угловой форме**. Ничего
|
||||
нового в этику отряда он не приносит:
|
||||
|
||||
| | стрелок | боец ближнего боя |
|
||||
|---|---|---|
|
||||
| зона поражения | отрезок «дуло → цель» | сектор `reach` × `arcDeg` вокруг оси удара |
|
||||
| правило безопасности | свой в отрезке → не стрелять | свой в секторе → не бить |
|
||||
| проверка вперёд | `AllyInterceptsShot` (успеет ли влезть за время полёта) | экстраполяция союзника на `windup + strike` |
|
||||
| срыв в последний момент | пуля уже летит, срывать нечего | взмах **обрывается** |
|
||||
| резерв в реестре линий (6.6) | отрезок до цели | ось удара с явной полушириной |
|
||||
| свой круг вокруг диска | `cohesionRadius` | `meleeCohesionRadius`, заметно больший |
|
||||
| выбор позиции | решатель огневых позиций | подход вплотную к цели |
|
||||
|
||||
Из этого следует главное свойство: **клинок физически не может поднять
|
||||
`FRIENDLY_HITS`**. Союзникам он урона не наносит вообще, а правило сектора
|
||||
существует не ради урона, а ради того, чтобы отряд выглядел как отряд.
|
||||
|
||||
## Состав отряда
|
||||
|
||||
`Squad::meleeCount` (по умолчанию `DEFAULT_MELEE_COUNT` = 2) — сколько **первых
|
||||
слотов строя** держат клинок. В клине это остриё и левое крыло, то есть те, кто
|
||||
и так упирается в противника первым. Ставится до `Squad::Init`; из командной
|
||||
строки — `--melee N`.
|
||||
|
||||
## Три фазы удара
|
||||
|
||||
```
|
||||
IDLE ──цель в reach, корпус довёрнут, сектор чист──> WINDUP ──> STRIKE ──> RECOVER ──> IDLE
|
||||
0.17 с 0.13 с 0.24 с
|
||||
интервал между НАЧАЛАМИ замахов: 0.60 с
|
||||
```
|
||||
|
||||
- **WINDUP** — замах. Ось удара уже зафиксирована и больше не доворачивается:
|
||||
доворот на этой фазе — это самонаведение, только клинком. Скорость ×0.20,
|
||||
ноги вкопаны. Игрок обязан успевать читать замах, иначе удар выглядит не
|
||||
ударом, а случайным списанием HP.
|
||||
- **STRIKE** — активное окно. Лезвие метёт сектор от одного края к другому,
|
||||
сторона захода чередуется от взмаха к взмаху. Здесь же **рывок**: seek,
|
||||
separation и расступание молчат, боец едет по оси удара со своей скоростью
|
||||
и своим ускорением. Стены — единственное, что его останавливает.
|
||||
- **RECOVER** — боец открыт и медленный (×0.55).
|
||||
|
||||
## Связка ударов
|
||||
|
||||
Чередование «влево-вправо» — это не связка, а метроном: два удара, между которыми
|
||||
нет разницы ни на глаз, ни по последствиям. Связка обязана иметь **форму**.
|
||||
|
||||
Таблица — `MELEE_COMBO` в [src/tuning.h](../src/tuning.h), множители к базовым
|
||||
характеристикам клинка:
|
||||
|
||||
| # | Удар | Сектор | Урон | Отброс | Замах | Пауза | Смысл |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | `slash` | ×1.00 | ×1.00 | ×1.00 | ×1.00 | ×1.00 | косой заход, с него всё начинается |
|
||||
| 2 | `backhand` | ×0.85 | ×1.00 | ×0.85 | ×0.55 | ×0.50 | возврат с обратной стороны, быстро и почти без паузы |
|
||||
| 3 | `spin` | ×1.70 | ×1.75 | ×2.30 | ×1.50 | ×1.35 | раскрутка: широкая, тяжёлая, с долгой расплатой |
|
||||
|
||||
Серия держится `meleeComboWindow` (1.1 с) после удара. Не продолжил вовремя —
|
||||
начинаешь с первого. Итог по времени: три удара за 1.71 с вместо трёх за 1.80 с,
|
||||
но суммарный урон выше в 1.25 раза — связка выгоднее ровного метронома, если её
|
||||
успевают довести.
|
||||
|
||||
Раскрутку в тесноте **просто не дадут начать**: её сектор проверяется той же
|
||||
дисциплиной, что и обычный удар, а он шире почти вдвое. Это не ограничение, а
|
||||
цена: широкий замах требует места. В замере связки: `slash 21 / backhand 19 /
|
||||
spin 15` — финиш доходит реже, и это видно в цифрах, а не на словах.
|
||||
|
||||
Параметры взмаха **запекаются на старте замаха** (`MeleeWeapon::BakeSwing`).
|
||||
Причина техническая и важная: базовые поля переписываются со снаряжения каждый
|
||||
шаг (`SyncLoadout`), и без запекания удар менял бы ширину и урон прямо в полёте.
|
||||
|
||||
## Удар на ходу
|
||||
|
||||
Бой на ходу — нормальный режим, а не исключение. Ограничение ровно одно: в
|
||||
**спринте** боец не гоняется за целями (`Squad::GoalFor` возвращает слот строя),
|
||||
но если цель сама оказалась под рукой — бьёт. Отдельного запрета «на бегу не
|
||||
драться» нет.
|
||||
|
||||
При этом на текущих числах поводка удержание направления спринт и не включает:
|
||||
отряд идёт быстрым шагом и работает (см. [05-squad-ai.md](05-squad-ai.md),
|
||||
равновесие поводка).
|
||||
|
||||
## Урон идёт по пройденной полосе, а не по факту удара
|
||||
|
||||
Каждый шаг считаются два угла лезвия: где оно было в начале шага и где стало в
|
||||
конце. Цель получает урон, если полоса шириной `meleeBladeDeg` прошла через неё
|
||||
**за этот шаг** (`BladeSweepHits`). Проверяется и «стоит на цели прямо сейчас»,
|
||||
и «проскочило насквозь за 1/60 с» — за шаг дуга проходит заметный угол.
|
||||
|
||||
### Почему это точная геометрия, а не угловой допуск
|
||||
|
||||
Лезвие — **отрезок** от бойца длиной `reach`, повернувшийся за шаг. Проверка
|
||||
работает с ним напрямую: попало ли направление на цель в заметённую полосу, а
|
||||
если нет — не задел ли цель край отрезка в крайних положениях.
|
||||
|
||||
Раньше здесь стоял угловой допуск `asin(r / dist)`. Он корректно описывает
|
||||
угловой размер тела, но как **допуск к сектору** вырождается: при `dist -> r`
|
||||
`asin` даёт 90°, сектор разрастается в полуплоскость, и боец рубит за спину.
|
||||
В фазе рывка он как раз проезжает вплотную — отсюда и брались удары «на 360
|
||||
градусов». Отрезок такого не допускает: для цели позади ближайшая точка лезвия —
|
||||
рукоять, а до неё ровно `dist`, что больше радиуса цели.
|
||||
|
||||
Инвариант закреплён критерием **M7.4**: замеряется, на сколько градусов
|
||||
попадание вышло за разрешённый сектор ЭТОГО взмаха. Норма — ноль.
|
||||
|
||||
Смысл: то, что видно на экране, и то, что получило урон, — одна и та же дуга.
|
||||
Разовый расчёт «в начале взмаха по всему сектору» дал бы попадания раньше, чем
|
||||
лезвие туда доедет, и это чувствуется.
|
||||
|
||||
Одна мишень получает урон **один раз за взмах** (`hitMask`), но один взмах может
|
||||
задеть несколько — мишени стоят кучками, и это ровно то, ради чего клинок нужен.
|
||||
Сквозь стену не рубит: перед уроном идёт `RaycastBlocked`.
|
||||
|
||||
## Дисциплина сектора
|
||||
|
||||
Проверяется дважды, как и у ствола:
|
||||
|
||||
1. **на старте замаха** — сектор `reach` × `arcDeg` обязан быть чист. Союзник
|
||||
проверяется в двух точках: где он сейчас и где будет через `windup + strike`
|
||||
(`meleeStartPredict`). Без этого треть взмахов начиналась впустую;
|
||||
2. **каждый шаг активного окна** — если свой всё-таки влез под лезвие, взмах
|
||||
**обрывается**: серая вспышка, `MELEE_ABORT`, сразу в `RECOVER`.
|
||||
|
||||
Оборванные взмахи считаются метрикой (`meleeAborts`) и видны в оверлее. Это не
|
||||
баг, а видимая цена скученности: примерно каждый пятый взмах в плотной свалке.
|
||||
|
||||
Угловой размер тела союзника считается честно (`asin(r / dist)`), поэтому
|
||||
вплотную союзник перекрывает почти весь сектор — так и должно быть.
|
||||
|
||||
## Свой, больший круг
|
||||
|
||||
Стрелок держится в `cohesionRadius` (2.1) от диска, и решатель **жёстко**
|
||||
отбраковывает огневые позиции дальше `cohesionRadius + postLeash`. Боец ближнего
|
||||
боя берёт цель, только если она внутри `meleeCohesionRadius` (5.6) от диска и не
|
||||
дальше `meleeSeekRadius` от него самого. Нет такой цели — возвращается в слот
|
||||
строя.
|
||||
|
||||
Поэтому классы живут в двух разных кругах вокруг одного диска: стрелки кучкой,
|
||||
клинки — на вылазке, но на поводке. Замеряется критерием M7.3.
|
||||
|
||||
Двое с клинками не вцепляются в одну мишень: цена цели растёт на
|
||||
`meleeClaimPenalty` за каждого своего, уже её выбравшего. Ближняя цель всё ещё
|
||||
выигрывает, но не любой ценой — вдвоём на одну это оборванные взмахи друг об
|
||||
друга и вдвое меньше урона (замер: 37 взмахов / 31 попадание против 74 / 75).
|
||||
|
||||
## Мишени получают отдачу
|
||||
|
||||
Удар отбрасывает мишень (`meleeKnockback`), импульс гасится и мишень возвращается
|
||||
на своё место (`home`). Без отдачи попадание в упор ничем не отличается от
|
||||
промаха. Расстановка уровня захардкожена, поэтому «уползание» мишеней исключено
|
||||
конструктивно: возврат идёт всегда, а респавн ставит `pos = home`.
|
||||
|
||||
Отдельно появился слой движения `TargetPushForce`: мишень — тело, а не пустое
|
||||
место. Без него боец стоял **внутри** того, кого рубит.
|
||||
|
||||
## Взаимодействие со стрельбой
|
||||
|
||||
- Боец ближнего боя — обычный союзник для `LaneClearFrom`. Стрелки сквозь него
|
||||
не стреляют, и решатель разводит их так, чтобы работать мимо него.
|
||||
- Пока идёт замах или удар, боец **не расступается** из чужих линий. Смысла нет:
|
||||
стрелок всё равно не выстрелит (правило 6.3 запрещает), а удар сорвётся.
|
||||
Расступается он до и после.
|
||||
- Рывок учтён в `AllyInterceptsShot` отдельной экстраполяцией: на замахе боец
|
||||
почти неподвижен и через мгновение выстреливает по уже известной оси, о чём
|
||||
его текущая скорость ничего не говорит. Проверяются обе точки — где он есть и
|
||||
куда прыгнет. Именно поэтому `MaxAgentSpeed()` намеренно **не** включает
|
||||
скорость рывка: она бы раздула запас для всех выстрелов сразу.
|
||||
|
||||
## Как это выглядит
|
||||
|
||||
[src/render/fx.cpp](../src/render/fx.cpp), `DrawMeleeArcs`. Дуга читается прямо
|
||||
из состояния агентов, а не из журнала событий: фаза живёт десятые доли секунды,
|
||||
её видно в каждом кадре, и привязка к живой позиции важнее — боец в момент удара
|
||||
едет вперёд.
|
||||
|
||||
- **WINDUP** — холодная стальная дуга уводится назад за край сектора и
|
||||
разгорается. Это телеграф.
|
||||
- **STRIKE** — серп из четырёх концентрических дуг: внешний край раскалён,
|
||||
внутренние тускнеют к рукояти. Одна линия на 480×270 читается палкой, четыре —
|
||||
взмахом. Хвост — доля пройденной дуги (`bladeTrailFrac`), а не вся: серп, а не
|
||||
кольцо. На голове — раскалённая радиальная черта и точка.
|
||||
- **RECOVER** — серп стоит на месте и быстро гаснет.
|
||||
|
||||
В изометрии круг — эллипс, поэтому дуга рисуется сэмплами мировых точек, а не
|
||||
экранной окружностью.
|
||||
|
||||
События `MELEE_SWING` / `MELEE_HIT` / `MELEE_ABORT` идут по тому же каналу
|
||||
`sim/events.h`, что и выстрелы: искры, вспышки света и тряска камеры (`meleeShake`
|
||||
заметно сильнее, чем на выстрел — удар всегда в упор).
|
||||
|
||||
Класс читается силуэтом: у бойца капсула шире и со стальным клинком. Цвет занят
|
||||
номером агента, поэтому классом он быть не может.
|
||||
|
||||
## Метрики
|
||||
|
||||
`FIRE UPTIME` усредняется **только по стрелкам**: линии огня у клинка не бывает
|
||||
по определению, и его ноль занижал бы метрику стрельбы. Отдельно считается
|
||||
`MELEE reach` — доля времени в зоне удара. Для `SETTLE TIME` «в бою» = стрелок
|
||||
при линии **или** клинок при цели.
|
||||
|
||||
Критерии M7.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` и не заметит разницы.
|
||||
|
||||
Что уже вынесено в параметры и годится под модификаторы: досягаемость, ширина
|
||||
сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание,
|
||||
скорость и ускорение рывка, множители скорости на замахе и восстановлении,
|
||||
радиус своего круга.
|
||||
BIN
docs/combat.png
Normal file
BIN
docs/combat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
BIN
lib/libgcc_s_dw2-1.dll
Normal file
BIN
lib/libgcc_s_dw2-1.dll
Normal file
Binary file not shown.
BIN
lib/libstdc++-6.dll
Normal file
BIN
lib/libstdc++-6.dll
Normal file
Binary file not shown.
58
main.code-workspace
Normal file
58
main.code-workspace
Normal file
@ -0,0 +1,58 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"files.associations": {
|
||||
"raylib.h": "c",
|
||||
"math.h": "c",
|
||||
"blocks.h": "c",
|
||||
"stdio.h": "c",
|
||||
"*.m": "c",
|
||||
"limits": "cpp",
|
||||
"array": "cpp",
|
||||
"atomic": "cpp",
|
||||
"*.tcc": "cpp",
|
||||
"cctype": "cpp",
|
||||
"clocale": "cpp",
|
||||
"cmath": "cpp",
|
||||
"cstdarg": "cpp",
|
||||
"cstddef": "cpp",
|
||||
"cstdint": "cpp",
|
||||
"cstdio": "cpp",
|
||||
"cstdlib": "cpp",
|
||||
"cwchar": "cpp",
|
||||
"cwctype": "cpp",
|
||||
"deque": "cpp",
|
||||
"unordered_map": "cpp",
|
||||
"vector": "cpp",
|
||||
"exception": "cpp",
|
||||
"algorithm": "cpp",
|
||||
"memory": "cpp",
|
||||
"memory_resource": "cpp",
|
||||
"optional": "cpp",
|
||||
"string": "cpp",
|
||||
"string_view": "cpp",
|
||||
"system_error": "cpp",
|
||||
"tuple": "cpp",
|
||||
"type_traits": "cpp",
|
||||
"utility": "cpp",
|
||||
"fstream": "cpp",
|
||||
"initializer_list": "cpp",
|
||||
"iosfwd": "cpp",
|
||||
"iostream": "cpp",
|
||||
"istream": "cpp",
|
||||
"new": "cpp",
|
||||
"ostream": "cpp",
|
||||
"sstream": "cpp",
|
||||
"stdexcept": "cpp",
|
||||
"streambuf": "cpp",
|
||||
"cinttypes": "cpp",
|
||||
"typeinfo": "cpp"
|
||||
},
|
||||
"git.autoRepositoryDetection": false,
|
||||
"git.enabled": false
|
||||
}
|
||||
}
|
||||
BIN
preview.jpg
Normal file
BIN
preview.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
342
src/ai/firing_solver.cpp
Normal file
342
src/ai/firing_solver.cpp
Normal file
@ -0,0 +1,342 @@
|
||||
#include "ai/firing_solver.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr float BIG_F = 1e18f;
|
||||
|
||||
// Дуло: muzzleOffset юнита от точки from в сторону цели.
|
||||
inline Vec2 MuzzleAt(const Agent& shooter, Vec2 from, Vec2 targetPos)
|
||||
{
|
||||
return from + Normalized(targetPos - from) * shooter.gun.def.muzzleOffset;
|
||||
}
|
||||
|
||||
// Полураствор конуса выстрела, выраженный как тангенс: боковое отклонение
|
||||
// пули на расстоянии L от дула равно tan * L.
|
||||
inline float SpreadTan(const Agent& shooter)
|
||||
{
|
||||
return std::tan(std::min(shooter.SpreadRad(), 1.2f));
|
||||
}
|
||||
|
||||
// Свободен ли конус «дуло -> цель» от своих.
|
||||
//
|
||||
// Раньше проверялся отрезок: пуля летела ровно по нему, и этого хватало.
|
||||
// Теперь у выстрела есть разброс, и проверять надо ровно ту область, куда
|
||||
// пуля может уйти, — конус. Ослабления правила 6.3 тут нет, наоборот: зона
|
||||
// проверки стала шире отрезка ровно настолько, насколько шире стал выстрел.
|
||||
// Расширение растёт вдоль линии (у дула конус нулевой), поэтому союзник,
|
||||
// стоящий вплотную к стрелку сбоку, лишнего запрета не создаёт.
|
||||
bool ConeClearOfAllies(const Agent& shooter, Vec2 muzzle, int shooterIndex, Vec2 targetPos,
|
||||
const SolverContext& ctx)
|
||||
{
|
||||
const float half = shooter.gun.def.laneHalfWidth + g_tune.safetyMargin;
|
||||
const float segLen = Distance(muzzle, targetPos);
|
||||
const float k = SpreadTan(shooter) * segLen;
|
||||
|
||||
for (int i = 0; i < ctx.agentCount; ++i)
|
||||
{
|
||||
if (i == shooterIndex) continue;
|
||||
const Agent& ally = ctx.agents[i];
|
||||
float t = 0.0f;
|
||||
const float d = DistancePointToSegment(ally.pos, muzzle, targetPos, &t);
|
||||
if (d < ally.bodyRadius + half + k * t) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Почему линии нет: свой или стена (для F2-оверлея).
|
||||
void ClassifyBlock(Vec2 from, int shooterIndex, Vec2 targetPos, const SolverContext& ctx,
|
||||
bool& byAlly, bool& byWall)
|
||||
{
|
||||
const Agent& shooter = ctx.agents[shooterIndex];
|
||||
const Vec2 muzzle = MuzzleAt(shooter, from, targetPos);
|
||||
|
||||
byAlly = !ConeClearOfAllies(shooter, muzzle, shooterIndex, targetPos, ctx);
|
||||
byWall = ctx.map->RaycastBlocked(muzzle, targetPos);
|
||||
}
|
||||
|
||||
// Куда союзник МОЖЕТ уехать за время t. Обычно это линейная экстраполяция
|
||||
// скорости, но боец ближнего боя на замахе стоит почти неподвижно и через
|
||||
// мгновение выстреливает рывком по УЖЕ ИЗВЕСТНОЙ оси удара — скорость об этом
|
||||
// ещё ничего не говорит. Поэтому для него проверяются обе точки: где он есть
|
||||
// и куда он прыгнет. Иначе выстрел «мимо стоящего» находит его в полёте.
|
||||
int PredictAllyPositions(const Agent& ally, float t, Vec2 out[2])
|
||||
{
|
||||
out[0] = ally.pos + ally.vel * t;
|
||||
if (!ally.IsMelee() || !ally.melee.Committed()) return 1;
|
||||
|
||||
out[1] = ally.pos + FromAngle(ally.melee.dir) * (g_tune.meleeLungeSpeed * t);
|
||||
return 2;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 6.3 LaneClear
|
||||
// -----------------------------------------------------------------------------
|
||||
bool LaneClearFrom(Vec2 from, int shooterIndex, Vec2 targetPos, const SolverContext& ctx)
|
||||
{
|
||||
const Agent& shooter = ctx.agents[shooterIndex];
|
||||
const Vec2 muzzle = MuzzleAt(shooter, from, targetPos);
|
||||
|
||||
// 1. свои — по конусу разброса, а не по осевому отрезку
|
||||
if (!ConeClearOfAllies(shooter, muzzle, shooterIndex, targetPos, ctx)) return false;
|
||||
|
||||
// 2. геометрия
|
||||
if (ctx.map->RaycastBlocked(muzzle, targetPos)) return false;
|
||||
|
||||
// 3. дистанция — своя у каждого ствола
|
||||
if (Distance(muzzle, targetPos) > shooter.gun.def.range) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AllyInterceptsShot(Vec2 muzzle, Vec2 targetPos, int shooterIndex, const SolverContext& ctx)
|
||||
{
|
||||
const Agent& shooter = ctx.agents[shooterIndex];
|
||||
const Vec2 dir = Normalized(targetPos - muzzle);
|
||||
const float speed = std::max(0.001f, shooter.gun.def.bulletSpeed);
|
||||
const float tImpact = Distance(muzzle, targetPos) / speed;
|
||||
// Пуля летит внутри конуса, поэтому её «толщина» растёт с пройденным путём.
|
||||
const float spreadK = SpreadTan(shooter);
|
||||
|
||||
// Идём по времени полёта: где будет пуля и где будет союзник.
|
||||
// Радиус безопасности растёт со временем — союзник может и повернуть,
|
||||
// а линейная экстраполяция скорости это не ловит.
|
||||
constexpr int SAMPLES = 6;
|
||||
for (int k = 0; k <= SAMPLES; ++k)
|
||||
{
|
||||
const float t = tImpact * (float(k) / float(SAMPLES));
|
||||
const Vec2 bulletPos = muzzle + dir * (speed * t);
|
||||
|
||||
for (int i = 0; i < ctx.agentCount; ++i)
|
||||
{
|
||||
if (i == shooterIndex) continue;
|
||||
const Agent& ally = ctx.agents[i];
|
||||
const float r = ally.bodyRadius + shooter.gun.def.bulletRadius + g_tune.safetyMargin +
|
||||
g_tune.shotPredictSlack * MaxAgentSpeed() * t +
|
||||
spreadK * speed * t;
|
||||
|
||||
Vec2 predicted[2];
|
||||
const int n = PredictAllyPositions(ally, t, predicted);
|
||||
for (int q = 0; q < n; ++q)
|
||||
if (DistanceSq(bulletPos, predicted[q]) < r * r) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int NearestAliveTarget(Vec2 from, const SolverContext& ctx, float searchRadius)
|
||||
{
|
||||
const std::vector<Target>& targets = *ctx.targets;
|
||||
int best = -1;
|
||||
float bestD = searchRadius;
|
||||
|
||||
for (int i = 0; i < int(targets.size()); ++i)
|
||||
{
|
||||
if (!targets[i].alive) continue;
|
||||
const float d = Distance(from, targets[i].pos);
|
||||
if (d < bestD)
|
||||
{
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Выбор цели: 6.4 (ближайшая с чистой линией) + 6.8 (приоритет назначенной).
|
||||
// -----------------------------------------------------------------------------
|
||||
void EvaluateAgentLane(Agent& a, int index, const SolverContext& ctx)
|
||||
{
|
||||
const std::vector<Target>& targets = *ctx.targets;
|
||||
|
||||
a.hasLane = false;
|
||||
a.priorityLane = false;
|
||||
a.laneIntent = false;
|
||||
a.currentTargetId = -1;
|
||||
a.targetBlockedByAlly = false;
|
||||
a.targetBlockedByWall = false;
|
||||
|
||||
// --- ручной режим: сначала назначенная цель ---
|
||||
if (ctx.assignedTargetId >= 0 && ctx.assignedTargetId < int(targets.size()) &&
|
||||
targets[ctx.assignedTargetId].alive)
|
||||
{
|
||||
const Vec2 tp = targets[ctx.assignedTargetId].pos;
|
||||
a.laneEnd = tp;
|
||||
|
||||
if (LaneClearFrom(a.pos, index, tp, ctx))
|
||||
{
|
||||
a.currentTargetId = ctx.assignedTargetId;
|
||||
a.hasLane = true;
|
||||
a.priorityLane = true;
|
||||
return;
|
||||
}
|
||||
ClassifyBlock(a.pos, index, tp, ctx, a.targetBlockedByAlly, a.targetBlockedByWall);
|
||||
a.laneIntent = a.targetBlockedByAlly && !a.targetBlockedByWall &&
|
||||
Distance(a.pos, tp) <= a.gun.def.range + a.gun.def.muzzleOffset;
|
||||
|
||||
// Вторичные цели — только в движении и только при включённом флаге (6.8).
|
||||
if (!(a.state == AgentState::ADVANCING && g_tune.freeFireSecondary)) return;
|
||||
}
|
||||
|
||||
// --- авто: ближайшая цель, до которой линия чиста ---
|
||||
int best = -1;
|
||||
float bestD = BIG_F;
|
||||
for (int i = 0; i < int(targets.size()); ++i)
|
||||
{
|
||||
if (!targets[i].alive) continue;
|
||||
const float d = Distance(a.pos, targets[i].pos);
|
||||
if (d >= bestD || d > a.gun.def.range + a.gun.def.muzzleOffset) continue;
|
||||
if (!LaneClearFrom(a.pos, index, targets[i].pos, ctx)) continue;
|
||||
best = i;
|
||||
bestD = d;
|
||||
}
|
||||
|
||||
if (best >= 0)
|
||||
{
|
||||
a.currentTargetId = best;
|
||||
a.hasLane = true;
|
||||
a.priorityLane = (ctx.assignedTargetId < 0);
|
||||
a.laneEnd = targets[best].pos;
|
||||
return;
|
||||
}
|
||||
|
||||
// Линии нет — для оверлея показываем ближайшую цель и причину.
|
||||
if (ctx.assignedTargetId < 0)
|
||||
{
|
||||
const int nearest = NearestAliveTarget(a.pos, ctx, a.gun.def.range);
|
||||
if (nearest >= 0)
|
||||
{
|
||||
a.laneEnd = targets[nearest].pos;
|
||||
ClassifyBlock(a.pos, index, targets[nearest].pos, ctx,
|
||||
a.targetBlockedByAlly, a.targetBlockedByWall);
|
||||
// Линию перекрыл только свой -> намерение стрелять остаётся, и линия
|
||||
// всё равно резервируется (6.6), иначе перекрывшего ничто не сдвинет.
|
||||
a.laneIntent = a.targetBlockedByAlly && !a.targetBlockedByWall;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 6.5 Оценка кандидата
|
||||
// -----------------------------------------------------------------------------
|
||||
float ScoreCandidate(Vec2 cand, const Agent& a, int index, const SolverContext& ctx,
|
||||
int bestTargetId)
|
||||
{
|
||||
const std::vector<Target>& targets = *ctx.targets;
|
||||
float score = 0.0f;
|
||||
|
||||
if (bestTargetId >= 0 && targets[bestTargetId].alive &&
|
||||
LaneClearFrom(cand, index, targets[bestTargetId].pos, ctx))
|
||||
score += g_tune.sLaneClear;
|
||||
|
||||
int shootable = 0;
|
||||
int total = 0;
|
||||
float nearestDist = BIG_F;
|
||||
for (int i = 0; i < int(targets.size()); ++i)
|
||||
{
|
||||
if (!targets[i].alive) continue;
|
||||
++total;
|
||||
nearestDist = std::min(nearestDist, Distance(cand, targets[i].pos));
|
||||
if (LaneClearFrom(cand, index, targets[i].pos, ctx)) ++shootable;
|
||||
}
|
||||
if (total > 0) score += g_tune.sCoverage * float(shootable) / float(total);
|
||||
|
||||
if (ctx.lanes)
|
||||
score -= g_tune.sBlockAlly * float(ctx.lanes->CountBlocked(index, cand, a.bodyRadius));
|
||||
|
||||
score -= g_tune.sDistCost * Distance(cand, a.pos);
|
||||
score -= g_tune.sCohesionCost *
|
||||
std::max(0.0f, Distance(cand, ctx.anchor) - g_tune.cohesionRadius);
|
||||
|
||||
if (nearestDist < g_tune.minEngageDist) score -= g_tune.sTooClose;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 6.5 Решатель: 3 кольца x 12 направлений + текущая позиция, коммит с гистерезисом
|
||||
// -----------------------------------------------------------------------------
|
||||
bool SolveFiringPosition(Agent& a, int index, const SolverContext& ctx, SolveResult& out)
|
||||
{
|
||||
const std::vector<Target>& targets = *ctx.targets;
|
||||
|
||||
out.count = 0;
|
||||
out.bestIndex = -1;
|
||||
out.committed = false;
|
||||
|
||||
// Цель решателя: назначенная (ручной режим) либо ближайшая достижимая.
|
||||
int bestTargetId = -1;
|
||||
if (ctx.assignedTargetId >= 0 && ctx.assignedTargetId < int(targets.size()) &&
|
||||
targets[ctx.assignedTargetId].alive)
|
||||
bestTargetId = ctx.assignedTargetId;
|
||||
else
|
||||
bestTargetId = NearestAliveTarget(a.pos, ctx, a.gun.def.range + g_tune.ring3);
|
||||
|
||||
out.bestTargetId = bestTargetId;
|
||||
if (bestTargetId < 0) return false; // стрелять не по чему — стоим
|
||||
|
||||
const float rings[SOLVER_RINGS] = {g_tune.ring1, g_tune.ring2, g_tune.ring3};
|
||||
|
||||
auto push = [&](Vec2 p, bool forceValid) {
|
||||
if (out.count >= SOLVER_CANDIDATES) return;
|
||||
const int i = out.count++;
|
||||
out.candidates[i] = p;
|
||||
out.valid[i] = true;
|
||||
out.scores[i] = 0.0f;
|
||||
|
||||
if (!forceValid)
|
||||
{
|
||||
// Отбраковка: вне карты / в стене / за поводком от диска /
|
||||
// слишком близко к другому агенту.
|
||||
if (p.x < 1.0f || p.y < 1.0f || p.x > float(MAP_W) - 1.0f || p.y > float(MAP_H) - 1.0f)
|
||||
out.valid[i] = false;
|
||||
else if (Distance(p, ctx.anchor) > g_tune.cohesionRadius + g_tune.postLeash)
|
||||
out.valid[i] = false;
|
||||
else if (ctx.map->CircleHitsWall(p, a.bodyRadius))
|
||||
out.valid[i] = false;
|
||||
else
|
||||
{
|
||||
for (int k = 0; k < ctx.agentCount; ++k)
|
||||
{
|
||||
if (k == index) continue;
|
||||
if (Distance(p, ctx.agents[k].pos) < g_tune.candMinAlly)
|
||||
{
|
||||
out.valid[i] = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (out.valid[i]) out.scores[i] = ScoreCandidate(p, a, index, ctx, bestTargetId);
|
||||
};
|
||||
|
||||
push(a.pos, true); // индекс 0 — текущая позиция, для сравнения
|
||||
out.currentScore = out.scores[0];
|
||||
|
||||
for (int r = 0; r < SOLVER_RINGS; ++r)
|
||||
for (int k = 0; k < SOLVER_DIRS; ++k)
|
||||
push(a.pos + FromAngle(float(k) * (TWO_PI_F / float(SOLVER_DIRS))) * rings[r], false);
|
||||
|
||||
out.bestScore = out.currentScore;
|
||||
for (int i = 1; i < out.count; ++i)
|
||||
{
|
||||
if (!out.valid[i]) continue;
|
||||
if (out.scores[i] > out.bestScore)
|
||||
{
|
||||
out.bestScore = out.scores[i];
|
||||
out.bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Коммит с гистерезисом — иначе агент дёргается между двумя точками.
|
||||
if (out.bestIndex > 0 && out.bestScore > out.currentScore + g_tune.hysteresis)
|
||||
{
|
||||
a.postPos = out.candidates[out.bestIndex];
|
||||
a.commitTimer = g_tune.commitTime;
|
||||
out.committed = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
68
src/ai/firing_solver.h
Normal file
68
src/ai/firing_solver.h
Normal file
@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ядро прототипа (спек 6.3–6.5): проверка линии огня, выбор цели,
|
||||
// решатель огневых позиций с гистерезисом.
|
||||
//
|
||||
// Всё здесь — чистые функции над состоянием мира. Модуль ai/ НЕ знает
|
||||
// ни про рендер, ни про изометрию, ни про ввод.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <vector>
|
||||
|
||||
#include "ai/lane_registry.h"
|
||||
#include "sim/agent.h"
|
||||
#include "sim/target.h"
|
||||
#include "world/tilemap.h"
|
||||
|
||||
struct SolverContext
|
||||
{
|
||||
const Tilemap* map = nullptr;
|
||||
const Agent* agents = nullptr;
|
||||
int agentCount = 0;
|
||||
const std::vector<Target>* targets = nullptr;
|
||||
const LaneRegistry* lanes = nullptr;
|
||||
|
||||
Vec2 anchor{};
|
||||
int assignedTargetId = -1; // ручной режим (6.8); -1 = авто
|
||||
};
|
||||
|
||||
// --- 6.3 ---------------------------------------------------------------------
|
||||
// Линия от дула стрелка (в точке from, ствол смотрит на цель) до targetPos.
|
||||
// Порядок проверок как в спеке: свои -> геометрия -> дистанция.
|
||||
bool LaneClearFrom(Vec2 from, int shooterIndex, Vec2 targetPos, const SolverContext& ctx);
|
||||
|
||||
// Успеет ли союзник влезть под пулю за время её полёта.
|
||||
//
|
||||
// 6.3 проверяет линию в МОМЕНТ выстрела, а 6.9 делает пулю настоящим снарядом:
|
||||
// до 0.4 с полёта, за которые союзник проходит больше метра. Реактивного
|
||||
// расступания (6.6) тут не хватает — союзник может быть чист в момент нажатия
|
||||
// и войти в коридор уже потом. Поэтому спуск дополнительно проверяет
|
||||
// сближение пули и союзника как двух равномерно движущихся точек.
|
||||
// Само правило 6.3 не ослабляется: см. docs/08-decisions.md.
|
||||
bool AllyInterceptsShot(Vec2 muzzle, Vec2 targetPos, int shooterIndex, const SolverContext& ctx);
|
||||
|
||||
// --- выбор цели (6.4 / 6.8) --------------------------------------------------
|
||||
// Заполняет currentTargetId, hasLane, priorityLane и диагностику для оверлея.
|
||||
void EvaluateAgentLane(Agent& a, int index, const SolverContext& ctx);
|
||||
|
||||
// Ближайшая живая мишень в радиусе searchRadius (или -1).
|
||||
int NearestAliveTarget(Vec2 from, const SolverContext& ctx, float searchRadius);
|
||||
|
||||
// --- 6.5 ---------------------------------------------------------------------
|
||||
struct SolveResult
|
||||
{
|
||||
Vec2 candidates[SOLVER_CANDIDATES]{};
|
||||
float scores[SOLVER_CANDIDATES]{};
|
||||
bool valid[SOLVER_CANDIDATES]{};
|
||||
int count = 0;
|
||||
int bestIndex = -1;
|
||||
float bestScore = 0.0f;
|
||||
float currentScore = 0.0f;
|
||||
bool committed = false;
|
||||
int bestTargetId = -1;
|
||||
};
|
||||
|
||||
float ScoreCandidate(Vec2 cand, const Agent& a, int index, const SolverContext& ctx,
|
||||
int bestTargetId);
|
||||
|
||||
// true, если решатель отработал (нашёл bestTarget и оценил кандидатов).
|
||||
bool SolveFiringPosition(Agent& a, int index, const SolverContext& ctx, SolveResult& out);
|
||||
55
src/ai/lane_registry.cpp
Normal file
55
src/ai/lane_registry.cpp
Normal file
@ -0,0 +1,55 @@
|
||||
#include "ai/lane_registry.h"
|
||||
|
||||
void LaneRegistry::Add(int shooter, Vec2 from, Vec2 to, float reserve)
|
||||
{
|
||||
if (count >= NUM_AGENTS) return;
|
||||
lanes[count++] = FireLane{from, to, shooter, reserve};
|
||||
}
|
||||
|
||||
Vec2 LaneRegistry::AvoidanceForce(int agentIndex, Vec2 pos, float bodyRadius, float maxSpeed) const
|
||||
{
|
||||
// Резерв шире, чем «тело уже в линии»: агент должен отходить ЗАРАНЕЕ,
|
||||
// иначе он успевает влезть под пулю, которая уже летит.
|
||||
const float wide = BlockRadius(bodyRadius) * g_tune.laneReserveScale;
|
||||
Vec2 force{};
|
||||
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
const FireLane& lane = lanes[i];
|
||||
if (lane.shooter == agentIndex) continue;
|
||||
|
||||
const float threshold = (lane.reserve > 0.0f) ? (bodyRadius + lane.reserve) : wide;
|
||||
const float d = DistancePointToSegment(pos, lane.from, lane.to);
|
||||
if (d >= threshold) continue;
|
||||
|
||||
const Vec2 axis = Normalized(lane.to - lane.from);
|
||||
if (LengthSq(axis) < 0.5f) continue;
|
||||
|
||||
// В сторону ближайшего края линии.
|
||||
const Vec2 n = Perp(axis);
|
||||
const float side = Dot(pos - lane.from, n);
|
||||
Vec2 away = (side >= 0.0f) ? n : Vec2{-n.x, -n.y};
|
||||
if (std::fabs(side) < 1e-4f) // ровно на оси — разводим детерминированно
|
||||
away = (agentIndex & 1) ? n : Vec2{-n.x, -n.y};
|
||||
|
||||
// Чем ближе к оси, тем сильнее; laneAvoidStrength делает отклик резче.
|
||||
const float strength = Clampf((1.0f - d / threshold) * g_tune.laneAvoidStrength, 0.0f, 1.0f);
|
||||
force += away * (maxSpeed * strength);
|
||||
}
|
||||
return force;
|
||||
}
|
||||
|
||||
int LaneRegistry::CountBlocked(int exceptShooter, Vec2 pos, float bodyRadius,
|
||||
bool shootingOnly) const
|
||||
{
|
||||
const float narrow = BlockRadius(bodyRadius);
|
||||
int n = 0;
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
if (lanes[i].shooter == exceptShooter) continue;
|
||||
if (shootingOnly && lanes[i].reserve > 0.0f) continue; // это сектор клинка
|
||||
const float threshold = (lanes[i].reserve > 0.0f) ? (bodyRadius + lanes[i].reserve) : narrow;
|
||||
if (DistancePointToSegment(pos, lanes[i].from, lanes[i].to) < threshold) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
51
src/ai/lane_registry.h
Normal file
51
src/ai/lane_registry.h
Normal file
@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Реестр активных линий огня (спек 6.6).
|
||||
//
|
||||
// Каждый шаг собираются отрезки «дуло стреляющего -> его цель». Любой агент,
|
||||
// чьё тело попало в чужую линию, получает импульс ПЕРПЕНДИКУЛЯРНО линии в
|
||||
// сторону ближайшего края. Работает в обоих состояниях отряда.
|
||||
//
|
||||
// Модуль ai/ не знает ни про рендер, ни про изометрию.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "core/math.h"
|
||||
#include "tuning.h"
|
||||
|
||||
struct FireLane
|
||||
{
|
||||
Vec2 from{}; // дуло
|
||||
Vec2 to{}; // цель
|
||||
int shooter = -1; // индекс агента-стрелка
|
||||
// Абсолютная полуширина резерва. 0 = обычная линия огня, ширина берётся
|
||||
// из правила 6.3. Клинок задаёт свою: его сектор шире отрезка, и союзник,
|
||||
// отжатый строго вбок от оси, оказался бы ровно во фланге дуги.
|
||||
float reserve = 0.0f;
|
||||
};
|
||||
|
||||
struct LaneRegistry
|
||||
{
|
||||
FireLane lanes[NUM_AGENTS];
|
||||
int count = 0;
|
||||
|
||||
void Clear() { count = 0; }
|
||||
void Add(int shooter, Vec2 from, Vec2 to, float reserve = 0.0f);
|
||||
|
||||
// Порог, при котором тело считается «в линии».
|
||||
static float BlockRadius(float bodyRadius)
|
||||
{
|
||||
return bodyRadius + g_tune.laneHalfWidth + g_tune.safetyMargin;
|
||||
}
|
||||
|
||||
// Импульс расступания для агента agentIndex (свои линии игнорируются).
|
||||
Vec2 AvoidanceForce(int agentIndex, Vec2 pos, float bodyRadius, float maxSpeed) const;
|
||||
|
||||
// Сколько чужих линий перекрывает точка pos (для оценки кандидата, 6.5).
|
||||
//
|
||||
// shootingOnly отсекает резервы клинков. Нужно ровно одному потребителю —
|
||||
// критерию 11.4 «агент уходит с чужой ЛИНИИ ОГНЯ за ~0.3 с»: сектор клинка
|
||||
// резервируется заведомо шире опасной зоны, и мерить по нему то же время
|
||||
// означало бы мерить другую величину. Решателю, наоборот, нужны оба:
|
||||
// стоять на дороге у топора так же плохо, как и под пулей.
|
||||
int CountBlocked(int exceptShooter, Vec2 pos, float bodyRadius,
|
||||
bool shootingOnly = false) const;
|
||||
};
|
||||
192
src/ai/melee.cpp
Normal file
192
src/ai/melee.cpp
Normal file
@ -0,0 +1,192 @@
|
||||
#include "ai/melee.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr float BIG_F = 1e18f;
|
||||
|
||||
// Угловой полуразмер тела радиуса r, видимого с расстояния dist.
|
||||
// Вплотную союзник перекрывает почти весь сектор — так и должно быть.
|
||||
float AngularHalfSize(float dist, float r)
|
||||
{
|
||||
return std::asin(Clampf(r / std::max(dist, r), 0.0f, 1.0f));
|
||||
}
|
||||
|
||||
// Годится ли мишень как цель ближнего боя: жива, внутри СВОЕГО круга бойца,
|
||||
// не дальше meleeSeekRadius от него самого и видна (за стену не бегаем —
|
||||
// путей в прототипе нет, а обход угла даёт слой wallAvoidance).
|
||||
bool MeleeCandidate(const Agent& a, const SolverContext& ctx, int id)
|
||||
{
|
||||
const Target& t = (*ctx.targets)[id];
|
||||
if (!t.alive) return false;
|
||||
if (Distance(t.pos, ctx.anchor) > g_tune.meleeCohesionRadius) return false;
|
||||
if (Distance(t.pos, a.pos) > g_tune.meleeSeekRadius) return false;
|
||||
if (ctx.map->RaycastBlocked(a.pos, t.pos)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void EvaluateMeleeTarget(Agent& a, int index, const SolverContext& ctx)
|
||||
{
|
||||
const std::vector<Target>& targets = *ctx.targets;
|
||||
|
||||
// Линии огня у бойца нет и быть не может — обнуляем всю стрелковую часть,
|
||||
// иначе он попадёт и в реестр линий, и в FIRE UPTIME.
|
||||
a.hasLane = false;
|
||||
a.priorityLane = false;
|
||||
a.laneIntent = false;
|
||||
a.targetBlockedByAlly = false;
|
||||
a.targetBlockedByWall = false;
|
||||
|
||||
MeleeWeapon& m = a.melee;
|
||||
|
||||
// Пока идёт замах или удар, цель не переназначается: ось уже зафиксирована.
|
||||
if (m.Committed() && m.targetId >= 0 && targets[m.targetId].alive)
|
||||
{
|
||||
a.currentTargetId = m.targetId;
|
||||
a.laneEnd = targets[m.targetId].pos;
|
||||
m.inReach = Distance(a.pos, targets[m.targetId].pos) <=
|
||||
m.reach + targets[m.targetId].radius;
|
||||
return;
|
||||
}
|
||||
|
||||
m.targetId = -1;
|
||||
m.inReach = false;
|
||||
|
||||
// Назначенная игроком цель приоритетна и здесь (6.8), но только если она
|
||||
// проходит те же условия — иначе боец убежал бы через всю карту.
|
||||
int best = -1;
|
||||
if (ctx.assignedTargetId >= 0 && ctx.assignedTargetId < int(targets.size()) &&
|
||||
MeleeCandidate(a, ctx, ctx.assignedTargetId))
|
||||
{
|
||||
best = ctx.assignedTargetId;
|
||||
}
|
||||
else
|
||||
{
|
||||
float bestCost = BIG_F;
|
||||
for (int i = 0; i < int(targets.size()); ++i)
|
||||
{
|
||||
if (!MeleeCandidate(a, ctx, i)) continue;
|
||||
|
||||
// Цена = дистанция + штраф за каждого своего, уже вцепившегося
|
||||
// в эту мишень. Ближняя цель всё ещё выигрывает, но не любой ценой:
|
||||
// вдвоём на одну — это оборванные взмахи друг об друга и ноль урона.
|
||||
float cost = Distance(a.pos, targets[i].pos);
|
||||
for (int k = 0; k < ctx.agentCount; ++k)
|
||||
{
|
||||
if (k == index) continue;
|
||||
if (ctx.agents[k].IsMelee() && ctx.agents[k].melee.targetId == i)
|
||||
cost += g_tune.meleeClaimPenalty;
|
||||
}
|
||||
if (cost >= bestCost) continue;
|
||||
best = i;
|
||||
bestCost = cost;
|
||||
}
|
||||
}
|
||||
|
||||
m.targetId = best;
|
||||
a.currentTargetId = best;
|
||||
if (best < 0) return;
|
||||
|
||||
a.laneEnd = targets[best].pos;
|
||||
m.inReach = Distance(a.pos, targets[best].pos) <= m.reach + targets[best].radius;
|
||||
m.arcBlockedByAlly = m.inReach && !MeleeArcClear(a, index, AngleOf(targets[best].pos - a.pos), ctx);
|
||||
}
|
||||
|
||||
Vec2 MeleeGoal(const Agent& a, const SolverContext& ctx, Vec2 fallbackSlot)
|
||||
{
|
||||
const MeleeWeapon& m = a.melee;
|
||||
if (m.targetId < 0) return fallbackSlot;
|
||||
|
||||
const Target& t = (*ctx.targets)[m.targetId];
|
||||
|
||||
// Встаём вплотную, но не внутрь: reach считается от центра тела, поэтому
|
||||
// с такой дистанции удар достаёт с запасом.
|
||||
const float stand = t.radius + a.bodyRadius + g_tune.meleeGap;
|
||||
const Vec2 away = Normalized(a.pos - t.pos, FromAngle(a.facing + PI_F));
|
||||
return t.pos + away * stand;
|
||||
}
|
||||
|
||||
bool MeleeArcClear(const Agent& a, int index, float axisAngle, const SolverContext& ctx)
|
||||
{
|
||||
const MeleeWeapon& m = a.melee;
|
||||
const float half = m.HalfArc();
|
||||
const float reach = m.reach + g_tune.meleeAllyMargin;
|
||||
|
||||
// Взмах живёт windup + strike секунд, и всё это время союзник продолжает
|
||||
// идти. Проверять только текущее положение — значит начинать удары, которые
|
||||
// всё равно придётся обрывать. Это ровно та же поправка, что и
|
||||
// AllyInterceptsShot для пули: правило не ослабляется, оно смотрит вперёд.
|
||||
const float horizon = (m.windup + m.strike) * g_tune.meleeStartPredict;
|
||||
|
||||
for (int i = 0; i < ctx.agentCount; ++i)
|
||||
{
|
||||
if (i == index) continue;
|
||||
const Agent& ally = ctx.agents[i];
|
||||
|
||||
const Vec2 probe[2] = {ally.pos, ally.pos + ally.vel * horizon};
|
||||
for (const Vec2& q : probe)
|
||||
{
|
||||
const Vec2 d = q - a.pos;
|
||||
const float dist = Length(d);
|
||||
if (dist > reach + ally.bodyRadius) continue;
|
||||
if (dist < 1e-4f) return false; // стоим в одной точке — бить нельзя
|
||||
|
||||
const float pad = AngularHalfSize(dist, ally.bodyRadius + g_tune.meleeAllyMargin);
|
||||
if (std::fabs(AngleDiff(axisAngle, AngleOf(d))) <= half + pad) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BladeSweepHits(Vec2 from, float angPrev, float angCur, float bladeHalf, float reach,
|
||||
Vec2 p, float pRadius)
|
||||
{
|
||||
const Vec2 d = p - from;
|
||||
const float dist = Length(d);
|
||||
if (dist > reach + pRadius) return false;
|
||||
|
||||
// Тела перекрываются — попадает любой взмах, и это честно: цель обнимает
|
||||
// рукоять. Случай редкий, отсечён отдельно, чтобы ниже не делить на ноль.
|
||||
if (dist <= pRadius) return true;
|
||||
|
||||
// Дальше — ТОЧНАЯ геометрия: лезвие это ОТРЕЗОК от бойца длиной reach,
|
||||
// повернувшийся за шаг с angPrev на angCur.
|
||||
//
|
||||
// Раньше здесь стоял угловой допуск asin(r/dist), и он вырождался: на
|
||||
// контактной дистанции (dist -> pRadius) asin даёт 90 градусов, «сектор»
|
||||
// разрастался в полуплоскость, и боец рубил за спину. В фазе рывка он как
|
||||
// раз проезжает вплотную — отсюда и брались удары «на 360 градусов».
|
||||
// Отрезок такого не допускает: для цели позади ближайшая точка лезвия —
|
||||
// рукоять, а до неё ровно dist, что больше pRadius.
|
||||
const float ang = AngleOf(d);
|
||||
const float sweep = AngleDiff(angPrev, angCur); // короткая дельта за шаг
|
||||
const float rel = AngleDiff(angPrev, ang); // где цель относительно старта
|
||||
|
||||
// Направление на цель попало в заметённую за шаг полосу — значит лезвие
|
||||
// прошло через неё (дистанцию уже проверили сверху).
|
||||
const float lo = std::min(0.0f, sweep) - bladeHalf;
|
||||
const float hi = std::max(0.0f, sweep) + bladeHalf;
|
||||
if (rel >= lo && rel <= hi) return true;
|
||||
|
||||
// Иначе цель могла быть задета краем: проверяем оба крайних положения.
|
||||
const float ends[2] = {angPrev, angCur};
|
||||
for (float a : ends)
|
||||
if (DistancePointToSegment(p, from, from + FromAngle(a) * reach) <= pRadius) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Vec2 MeleeStrikeEnd(const Agent& a, const SolverContext& ctx)
|
||||
{
|
||||
const MeleeWeapon& m = a.melee;
|
||||
// На замахе и ударе ось уже зафиксирована, до него — направление на цель.
|
||||
float axis = m.dir;
|
||||
if (!m.Committed())
|
||||
{
|
||||
if (m.targetId < 0) return a.pos;
|
||||
axis = AngleOf((*ctx.targets)[m.targetId].pos - a.pos);
|
||||
}
|
||||
return a.pos + FromAngle(axis) * m.reach;
|
||||
}
|
||||
37
src/ai/melee.h
Normal file
37
src/ai/melee.h
Normal file
@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ближний бой: выбор цели, точка подхода, дисциплина сектора.
|
||||
//
|
||||
// Это ровно тот же контракт, что и у стрельбы (ai/firing_solver.h), только
|
||||
// геометрия угловая, а не линейная:
|
||||
//
|
||||
// стрелок боец ближнего боя
|
||||
// ---------------------------- --------------------------------------------
|
||||
// отрезок дуло -> цель сектор reach x arcDeg вокруг оси удара
|
||||
// свой в отрезке -> не стрелять свой в секторе -> не бить
|
||||
// решатель огневых позиций подход вплотную к цели
|
||||
// cohesionRadius (свой круг) meleeCohesionRadius (СВОЙ, больший круг)
|
||||
//
|
||||
// Модуль ai/ по-прежнему не знает ни про рендер, ни про изометрию, ни про ввод.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "ai/firing_solver.h"
|
||||
|
||||
// Выбор цели ближнего боя. Заполняет melee.targetId / melee.inReach и
|
||||
// currentTargetId (чтобы разворот ствола, оверлей и рендер работали как есть).
|
||||
// hasLane у бойца всегда false: стрелять ему нечем.
|
||||
void EvaluateMeleeTarget(Agent& a, int index, const SolverContext& ctx);
|
||||
|
||||
// Куда идти бойцу. Есть цель — вплотную к ней, нет — обратно в слот строя.
|
||||
Vec2 MeleeGoal(const Agent& a, const SolverContext& ctx, Vec2 fallbackSlot);
|
||||
|
||||
// В секторе замаха нет своих. Аналог LaneClearFrom для клинка.
|
||||
bool MeleeArcClear(const Agent& a, int index, float axisAngle, const SolverContext& ctx);
|
||||
|
||||
// Прошло ли лезвие через круг (p, pRadius) за шаг, на котором его угол
|
||||
// сместился с angPrev на angCur. bladeHalf — половина ширины самой полосы.
|
||||
bool BladeSweepHits(Vec2 from, float angPrev, float angCur, float bladeHalf, float reach,
|
||||
Vec2 p, float pRadius);
|
||||
|
||||
// Конец отрезка, который боец резервирует в реестре линий (6.6): ось удара
|
||||
// длиной reach. Свои расступаются с траектории клинка так же, как с линии огня.
|
||||
Vec2 MeleeStrikeEnd(const Agent& a, const SolverContext& ctx);
|
||||
36
src/core/hash.h
Normal file
36
src/core/hash.h
Normal file
@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Чистые хеш-функции без состояния. Значение зависит ТОЛЬКО от аргументов.
|
||||
//
|
||||
// Это не генератор случайных чисел: общего состояния нет, «посеять» нечего,
|
||||
// последовательность вызовов ни на что не влияет. Поэтому такой хеш можно
|
||||
// звать и из симуляции, не ломая детерминизм: --headless по-прежнему выдаёт
|
||||
// один и тот же прогон, а разброс выстрела воспроизводится по номеру выстрела
|
||||
// и индексу стрелка (см. sim/loadout.h и docs/08-decisions.md).
|
||||
//
|
||||
// Рендер использует те же функции через render/noise.h.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <cstdint>
|
||||
|
||||
inline uint32_t HashU32(uint32_t x)
|
||||
{
|
||||
x ^= x >> 16;
|
||||
x *= 0x7FEB352Du;
|
||||
x ^= x >> 15;
|
||||
x *= 0x846CA68Bu;
|
||||
x ^= x >> 16;
|
||||
return x;
|
||||
}
|
||||
|
||||
inline uint32_t Hash2(uint32_t x, uint32_t y) { return HashU32(x * 0x27D4EB2Du ^ (y + 0x165667B1u)); }
|
||||
|
||||
inline uint32_t Hash3(uint32_t x, uint32_t y, uint32_t z)
|
||||
{
|
||||
return HashU32(Hash2(x, y) ^ (z * 0x9E3779B9u));
|
||||
}
|
||||
|
||||
// [0, 1)
|
||||
inline float Hash01(uint32_t h) { return float(h & 0x00FFFFFFu) * (1.0f / 16777216.0f); }
|
||||
|
||||
// [-1, 1)
|
||||
inline float HashSigned(uint32_t h) { return Hash01(h) * 2.0f - 1.0f; }
|
||||
17
src/core/input.h
Normal file
17
src/core/input.h
Normal file
@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ввод, который влияет на симуляцию. Собирается в main.cpp из raylib,
|
||||
// в headless-прогонах заполняется скриптом. Отладочные клавиши (F1..F4, тюнинг)
|
||||
// сюда не попадают — они не влияют на симуляцию.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "core/math.h"
|
||||
|
||||
struct InputState
|
||||
{
|
||||
Vec2 moveScreen{}; // WASD в ЭКРАННЫХ осях, -1..1
|
||||
bool sprint = false; // Shift — рывок
|
||||
bool toggleAimMode = false; // Space (фронт нажатия)
|
||||
float aimTurn = 0.0f; // стрелки влево/вправо, -1..1
|
||||
bool cycleTarget = false; // Tab (фронт нажатия)
|
||||
int setFormation = -1; // 1/2/3 -> 0/1/2, иначе -1
|
||||
};
|
||||
107
src/core/math.h
Normal file
107
src/core/math.h
Normal file
@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Базовая математика прототипа. Только декартов мир (top-down), никакой изометрии.
|
||||
// Header-only: всё inline, никаких аллокаций.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
constexpr float PI_F = 3.14159265358979323846f;
|
||||
constexpr float TWO_PI_F = 6.28318530717958647692f;
|
||||
constexpr float DEG2RAD_F = PI_F / 180.0f;
|
||||
|
||||
struct Vec2
|
||||
{
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
};
|
||||
|
||||
inline Vec2 operator+(Vec2 a, Vec2 b) { return {a.x + b.x, a.y + b.y}; }
|
||||
inline Vec2 operator-(Vec2 a, Vec2 b) { return {a.x - b.x, a.y - b.y}; }
|
||||
inline Vec2 operator*(Vec2 a, float s) { return {a.x * s, a.y * s}; }
|
||||
inline Vec2 operator*(float s, Vec2 a) { return {a.x * s, a.y * s}; }
|
||||
inline Vec2 operator/(Vec2 a, float s) { return {a.x / s, a.y / s}; }
|
||||
inline Vec2 operator-(Vec2 a) { return {-a.x, -a.y}; }
|
||||
inline Vec2& operator+=(Vec2& a, Vec2 b) { a.x += b.x; a.y += b.y; return a; }
|
||||
inline Vec2& operator-=(Vec2& a, Vec2 b) { a.x -= b.x; a.y -= b.y; return a; }
|
||||
inline Vec2& operator*=(Vec2& a, float s) { a.x *= s; a.y *= s; return a; }
|
||||
|
||||
inline float Dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
|
||||
inline float Cross(Vec2 a, Vec2 b) { return a.x * b.y - a.y * b.x; }
|
||||
inline float LengthSq(Vec2 a) { return a.x * a.x + a.y * a.y; }
|
||||
inline float Length(Vec2 a) { return std::sqrt(LengthSq(a)); }
|
||||
inline float DistanceSq(Vec2 a, Vec2 b) { return LengthSq(b - a); }
|
||||
inline float Distance(Vec2 a, Vec2 b) { return Length(b - a); }
|
||||
|
||||
// Перпендикуляр (поворот на +90°).
|
||||
inline Vec2 Perp(Vec2 a) { return {-a.y, a.x}; }
|
||||
|
||||
inline Vec2 Normalized(Vec2 a, Vec2 fallback = {0.0f, 0.0f})
|
||||
{
|
||||
const float len = Length(a);
|
||||
return (len > 1e-6f) ? Vec2{a.x / len, a.y / len} : fallback;
|
||||
}
|
||||
|
||||
// Ограничить длину вектора сверху.
|
||||
inline Vec2 ClampLength(Vec2 a, float maxLen)
|
||||
{
|
||||
const float len = Length(a);
|
||||
if (len > maxLen && len > 1e-6f) return a * (maxLen / len);
|
||||
return a;
|
||||
}
|
||||
|
||||
inline float Clampf(float v, float lo, float hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
inline float Lerpf(float a, float b, float t) { return a + (b - a) * t; }
|
||||
inline Vec2 Lerp(Vec2 a, Vec2 b, float t) { return {Lerpf(a.x, b.x, t), Lerpf(a.y, b.y, t)}; }
|
||||
|
||||
// --- углы -------------------------------------------------------------------
|
||||
|
||||
inline float AngleOf(Vec2 v) { return std::atan2(v.y, v.x); }
|
||||
inline Vec2 FromAngle(float a) { return {std::cos(a), std::sin(a)}; }
|
||||
|
||||
// Приводит угол к (-pi, pi].
|
||||
inline float WrapPi(float a)
|
||||
{
|
||||
while (a > PI_F) a -= TWO_PI_F;
|
||||
while (a <= -PI_F) a += TWO_PI_F;
|
||||
return a;
|
||||
}
|
||||
|
||||
// Кратчайшая разница углов to-from в (-pi, pi].
|
||||
inline float AngleDiff(float from, float to) { return WrapPi(to - from); }
|
||||
|
||||
inline float RotateToward(float cur, float target, float maxDelta)
|
||||
{
|
||||
const float d = AngleDiff(cur, target);
|
||||
if (std::fabs(d) <= maxDelta) return WrapPi(target);
|
||||
return WrapPi(cur + (d > 0.0f ? maxDelta : -maxDelta));
|
||||
}
|
||||
|
||||
// --- сегменты ---------------------------------------------------------------
|
||||
|
||||
struct Segment
|
||||
{
|
||||
Vec2 a;
|
||||
Vec2 b;
|
||||
};
|
||||
|
||||
// Ближайшая точка отрезка ab к p; outT (если нужен) — параметр вдоль отрезка [0..1].
|
||||
inline Vec2 ClosestPointOnSegment(Vec2 p, Vec2 a, Vec2 b, float* outT = nullptr)
|
||||
{
|
||||
const Vec2 ab = b - a;
|
||||
const float lenSq = LengthSq(ab);
|
||||
float t = 0.0f;
|
||||
if (lenSq > 1e-12f) t = Clampf(Dot(p - a, ab) / lenSq, 0.0f, 1.0f);
|
||||
if (outT) *outT = t;
|
||||
return a + ab * t;
|
||||
}
|
||||
|
||||
inline float DistancePointToSegment(Vec2 p, Vec2 a, Vec2 b, float* outT = nullptr)
|
||||
{
|
||||
return Distance(p, ClosestPointOnSegment(p, a, b, outT));
|
||||
}
|
||||
|
||||
inline float DistancePointToSegment(Vec2 p, const Segment& s, float* outT = nullptr)
|
||||
{
|
||||
return DistancePointToSegment(p, s.a, s.b, outT);
|
||||
}
|
||||
603
src/debug/harness.cpp
Normal file
603
src/debug/harness.cpp
Normal file
@ -0,0 +1,603 @@
|
||||
#include "debug/harness.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
|
||||
#include "game.h"
|
||||
|
||||
// Перевод экранных осей ввода в мировые — единственное место,
|
||||
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
|
||||
#include "render/iso.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Мини-форматтер для строк вердикта (raylib в этот модуль не тащим).
|
||||
const char* Fmt(const char* fmt, ...)
|
||||
{
|
||||
static char buf[4][256];
|
||||
static int slot = 0;
|
||||
slot = (slot + 1) % 4;
|
||||
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
vsnprintf(buf[slot], sizeof(buf[slot]), fmt, args);
|
||||
va_end(args);
|
||||
return buf[slot];
|
||||
}
|
||||
|
||||
// --- маршрут для --headless ---------------------------------------------------
|
||||
struct Waypoint
|
||||
{
|
||||
float x, y;
|
||||
float hold; // сколько стоять по прибытии, сек
|
||||
int formation; // -1 = не менять
|
||||
};
|
||||
|
||||
const Waypoint kRoute[] = {
|
||||
{24.0f, 26.0f, 8.0f, 0}, // стоячий бой на арене у кучки из 5
|
||||
{31.0f, 20.0f, 6.0f, -1}, // вплотную к кучке
|
||||
{31.0f, 16.5f, 0.0f, 1}, // ко входу в северный коридор (2 тайла)
|
||||
{31.0f, 11.5f, 2.0f, -1}, // сквозь коридор
|
||||
{24.5f, 7.5f, 8.0f, 0}, // бой в северной комнате
|
||||
{31.0f, 11.5f, 0.0f, -1},
|
||||
{31.0f, 17.5f, 2.0f, -1}, // обратно на арену
|
||||
{20.0f, 29.0f, 4.0f, 2},
|
||||
{16.0f, 31.0f, 0.0f, 1}, // ко входу в западный коридор
|
||||
{11.0f, 31.0f, 2.0f, -1}, // сквозь него
|
||||
{ 8.5f, 38.5f, 8.0f, 0}, // бой в западной комнате
|
||||
{11.0f, 31.0f, 0.0f, -1},
|
||||
{17.0f, 31.0f, 2.0f, -1},
|
||||
{24.0f, 26.0f, 6.0f, 0},
|
||||
};
|
||||
constexpr int kRouteCount = int(sizeof(kRoute) / sizeof(kRoute[0]));
|
||||
constexpr float kArriveDist = 0.7f;
|
||||
constexpr float kWaypointTimeout = 20.0f;
|
||||
|
||||
// Мировое направление -> экранные оси: ровно то, что делает игрок клавишами.
|
||||
Vec2 MoveInputToward(const Game& g, Vec2 goal)
|
||||
{
|
||||
return Normalized(iso::WorldToScreen(Normalized(goal - g.squad.anchor)));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Наблюдатель: собирает то, что нельзя увидеть в одном кадре.
|
||||
// * сколько агент непрерывно торчит в чужой линии огня (критерий 11.4);
|
||||
// * как часто меняется postPos (критерий 11.5 — «нет дёрганья»).
|
||||
// -----------------------------------------------------------------------------
|
||||
struct Watcher
|
||||
{
|
||||
float residence[NUM_AGENTS] = {}; // текущее время внутри чужой линии
|
||||
float maxResidence = 0.0f;
|
||||
|
||||
// «Дёрганье» = решатель перекоммитил postPos раньше, чем истёк commitTime.
|
||||
// Коммит виден по скачку commitTimer вверх. Смена состояния отряда сбрасывает
|
||||
// отсчёт: это новая обстановка, а не дрожание.
|
||||
float prevCommitTimer[NUM_AGENTS] = {};
|
||||
float sinceCommit[NUM_AGENTS] = {};
|
||||
bool hadCommit[NUM_AGENTS] = {};
|
||||
float minCommitInterval = 1e9f;
|
||||
int commits = 0;
|
||||
int jitterViolations = 0;
|
||||
AgentState prevState = AgentState::HOLDING;
|
||||
|
||||
// Граница сценария. Накопленные максимумы и счётчики остаются (они про весь
|
||||
// прогон), а вот «когда был прошлый коммит» — нет: следующий сценарий это
|
||||
// другой Game с другим отрядом, и мерить между ними интервал бессмысленно.
|
||||
// Без этого первый же коммит нового сценария выглядел дёрганьем.
|
||||
void BeginScenario()
|
||||
{
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
hadCommit[i] = false;
|
||||
sinceCommit[i] = 0.0f;
|
||||
prevCommitTimer[i] = 0.0f;
|
||||
residence[i] = 0.0f;
|
||||
}
|
||||
prevState = AgentState::HOLDING;
|
||||
}
|
||||
|
||||
void Observe(const Game& g, float dt)
|
||||
{
|
||||
const bool stateChanged = (g.squad.state != prevState);
|
||||
prevState = g.squad.state;
|
||||
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
const Agent& a = g.squad.agents[i];
|
||||
|
||||
// Только линии огня: 11.4 про них, сектор клинка резервируется
|
||||
// заведомо шире опасной зоны (см. ai/lane_registry.h).
|
||||
const bool inside = g.lanes.CountBlocked(i, a.pos, a.bodyRadius, true) > 0;
|
||||
residence[i] = inside ? residence[i] + dt : 0.0f;
|
||||
maxResidence = std::max(maxResidence, residence[i]);
|
||||
|
||||
if (stateChanged) hadCommit[i] = false;
|
||||
|
||||
sinceCommit[i] += dt;
|
||||
if (a.commitTimer > prevCommitTimer[i] + 1e-4f) // произошёл коммит
|
||||
{
|
||||
++commits;
|
||||
if (hadCommit[i])
|
||||
{
|
||||
minCommitInterval = std::min(minCommitInterval, sinceCommit[i]);
|
||||
if (sinceCommit[i] < g_tune.commitTime - FIXED_DT)
|
||||
{
|
||||
++jitterViolations;
|
||||
std::printf(" JITTER #%d dt=%.3f state=%s melee=%d prio=%d lane=%d "
|
||||
"post(%.2f,%.2f) t=%.2f\n",
|
||||
i, double(sinceCommit[i]),
|
||||
g.squad.state == AgentState::ADVANCING ? "ADV" : "HOLD",
|
||||
int(a.IsMelee()), int(a.priorityLane), int(a.hasLane),
|
||||
double(a.postPos.x), double(a.postPos.y),
|
||||
double(g.simTime));
|
||||
}
|
||||
}
|
||||
hadCommit[i] = true;
|
||||
sinceCommit[i] = 0.0f;
|
||||
}
|
||||
prevCommitTimer[i] = a.commitTimer;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
int CountLanes(const Game& g)
|
||||
{
|
||||
int n = 0;
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
if (g.squad.agents[i].hasLane) ++n;
|
||||
return n;
|
||||
}
|
||||
|
||||
void PrintVerdict(const char* name, bool pass, const char* detail)
|
||||
{
|
||||
std::printf("[%s] %-52s %s\n", pass ? "PASS" : "FAIL", name, detail);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// --headless: маршрут + метрики
|
||||
// -----------------------------------------------------------------------------
|
||||
int RunHeadless(const HarnessOptions& opt)
|
||||
{
|
||||
Game game;
|
||||
game.enableSolver = opt.solver;
|
||||
game.enableLaneAvoid = opt.laneAvoid;
|
||||
game.squad.meleeCount = opt.meleeCount; // до Init: состав раздаётся там
|
||||
game.squad.pistolCount = opt.pistolCount;
|
||||
game.Init();
|
||||
|
||||
Watcher watch;
|
||||
const int totalSteps = int(opt.seconds / FIXED_DT);
|
||||
int wp = 0;
|
||||
bool holding = false;
|
||||
float phaseClock = 0.0f;
|
||||
int lastFormation = -1;
|
||||
double simSeconds = 0.0;
|
||||
float nextReport = 10.0f;
|
||||
float worstSettle = -1.0f;
|
||||
float worstHoldUptime = 1e9f;
|
||||
|
||||
std::printf("headless: %.0f s, solver=%d laneAvoid=%d melee=%d/%d\n", double(opt.seconds),
|
||||
int(opt.solver), int(opt.laneAvoid), game.squad.meleeCount, NUM_AGENTS);
|
||||
std::printf(" t friendly shots kills uptime%% settle lanes\n");
|
||||
|
||||
for (int step = 0; step < totalSteps; ++step)
|
||||
{
|
||||
const Waypoint& p = kRoute[wp];
|
||||
const float distToGoal = Distance(game.squad.anchor, {p.x, p.y});
|
||||
|
||||
InputState in;
|
||||
if (!holding) in.moveScreen = MoveInputToward(game, {p.x, p.y});
|
||||
if (p.formation >= 0 && p.formation != lastFormation)
|
||||
{
|
||||
in.setFormation = p.formation;
|
||||
lastFormation = p.formation;
|
||||
}
|
||||
game.in = in;
|
||||
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
game.Step(FIXED_DT);
|
||||
simSeconds += std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
|
||||
watch.Observe(game, FIXED_DT);
|
||||
|
||||
phaseClock += FIXED_DT;
|
||||
if (!holding)
|
||||
{
|
||||
if (distToGoal < kArriveDist || phaseClock > kWaypointTimeout)
|
||||
{
|
||||
holding = true;
|
||||
phaseClock = 0.0f;
|
||||
}
|
||||
}
|
||||
else if (phaseClock >= p.hold)
|
||||
{
|
||||
if (p.hold >= 4.0f)
|
||||
{
|
||||
std::printf(" hold @ (%.0f,%.0f) %.0fs -> lanes %d/%d uptime %.1f%% settle %.2f\n",
|
||||
double(p.x), double(p.y), double(p.hold), CountLanes(game), NUM_AGENTS,
|
||||
double(game.metrics.uptimeAvg), double(game.metrics.lastSettle));
|
||||
worstHoldUptime = std::min(worstHoldUptime, game.metrics.uptimeAvg);
|
||||
|
||||
if (opt.probe)
|
||||
{
|
||||
const SolverContext ctx = game.MakeContext();
|
||||
std::printf(" живых мишеней %d/%d\n", CountAliveTargets(game.targets),
|
||||
NUM_TARGETS);
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
const Agent& a = game.squad.agents[i];
|
||||
const int nearest = NearestAliveTarget(a.pos, ctx, 1e9f);
|
||||
const float d =
|
||||
(nearest >= 0) ? Distance(a.pos, game.targets[nearest].pos) : -1.0f;
|
||||
std::printf(" #%d pos(%.1f,%.1f) lane=%d t=%d ally=%d wall=%d "
|
||||
"nearest=%.1f post(%.1f,%.1f) uptime=%.0f%%\n",
|
||||
i, double(a.pos.x), double(a.pos.y), int(a.hasLane),
|
||||
a.currentTargetId, int(a.targetBlockedByAlly),
|
||||
int(a.targetBlockedByWall), double(d), double(a.postPos.x),
|
||||
double(a.postPos.y), double(game.metrics.uptimePct[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
holding = false;
|
||||
phaseClock = 0.0f;
|
||||
wp = (wp + 1) % kRouteCount;
|
||||
}
|
||||
|
||||
worstSettle = std::max(worstSettle, game.metrics.lastSettle);
|
||||
|
||||
if (game.simTime >= nextReport)
|
||||
{
|
||||
nextReport += 10.0f;
|
||||
std::printf("%6.1f %8d %6d %6d %7.1f %6.2f %d/%d\n", double(game.simTime),
|
||||
game.metrics.friendlyHits, game.metrics.shotsFired,
|
||||
game.metrics.targetsKilled, double(game.metrics.uptimeAvg),
|
||||
double(game.metrics.lastSettle), CountLanes(game), NUM_AGENTS);
|
||||
}
|
||||
}
|
||||
|
||||
const double avgUs = (totalSteps > 0) ? (simSeconds * 1e6 / totalSteps) : 0.0;
|
||||
std::printf("---\nFRIENDLY_HITS = %d\nshots = %d kills = %d\n", game.metrics.friendlyHits,
|
||||
game.metrics.shotsFired, game.metrics.targetsKilled);
|
||||
std::printf("FIRE UPTIME avg = %.1f%% (по агентам:", double(game.metrics.uptimeAvg));
|
||||
for (int i = 0; i < NUM_AGENTS; ++i) std::printf(" %.0f", double(game.metrics.uptimePct[i]));
|
||||
std::printf(")\nхудший uptime на стоянке = %.1f%%\n", double(worstHoldUptime));
|
||||
std::printf("SETTLE last = %.2f s, worst = %.2f s\n", double(game.metrics.lastSettle),
|
||||
double(worstSettle));
|
||||
if (game.squad.meleeCount > 0)
|
||||
std::printf("MELEE: в зоне удара %.1f%% времени, взмахов %d, попаданий %d, оборвано %d\n",
|
||||
double(game.metrics.meleeUptimeAvg), game.metrics.meleeSwings,
|
||||
game.metrics.meleeHits, game.metrics.meleeAborts);
|
||||
std::printf("макс. время в чужой линии = %.2f s, дёрганий postPos = %d\n",
|
||||
double(watch.maxResidence), watch.jitterViolations);
|
||||
std::printf("шаг симуляции в среднем = %.1f us (бюджет кадра 16666 us)\n", avgUs);
|
||||
|
||||
for (int i = 0; i < game.metrics.hitLogCount; ++i)
|
||||
{
|
||||
const FriendlyHitEvent& e = game.metrics.hitLog[i];
|
||||
std::printf(" friendly hit t=%.2f #%d -> #%d @ (%.1f,%.1f), жертва %s\n", double(e.time),
|
||||
e.shooter, e.victim, double(e.pos.x), double(e.pos.y),
|
||||
e.victimAdvancing ? "ADVANCING" : "HOLDING");
|
||||
}
|
||||
return game.metrics.friendlyHits == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// --accept: сценарии из раздела 11
|
||||
// -----------------------------------------------------------------------------
|
||||
int RunAccept()
|
||||
{
|
||||
bool allPass = true;
|
||||
Watcher watch; // общий: коммиты postPos по всем сценариям
|
||||
Watcher arenaWatch; // открытая арена: там и меряется критерий 11.4
|
||||
|
||||
// Критерии 11.1 / 11.6 / 11.7 / 11.8 описывают ПРАВИЛА СТРЕЛЬБЫ и меряются
|
||||
// на отряде из пяти стрелков — так же, как их формулировал спек. Клинок
|
||||
// проверяется отдельно (блок M7 ниже) и в трёхминутном прогоне 11.2, где
|
||||
// состав отряда обычный, боевой.
|
||||
const int kSpecSquadMelee = 0;
|
||||
const int kSpecSquadPistol = 0;
|
||||
|
||||
// === 11.1 + 11.8: отряд в куче на открытой арене, игрок останавливается ====
|
||||
{
|
||||
Game game;
|
||||
game.squad.meleeCount = kSpecSquadMelee;
|
||||
game.squad.pistolCount = kSpecSquadPistol;
|
||||
game.Init();
|
||||
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
||||
game.PlaceSquad({28.0f, 23.5f}, true); // кучка из 5 мишеней в радиусе
|
||||
|
||||
// 0.4 с движения, чтобы отряд был в ADVANCING, потом игрок отпускает клавиши
|
||||
for (int i = 0; i < int(0.4f / FIXED_DT); ++i)
|
||||
{
|
||||
game.in = InputState{};
|
||||
game.in.moveScreen = {0.0f, 1.0f};
|
||||
game.Step(FIXED_DT);
|
||||
}
|
||||
|
||||
float clock = 0.0f;
|
||||
float settle4 = -1.0f;
|
||||
for (int i = 0; i < int(25.0f / FIXED_DT); ++i)
|
||||
{
|
||||
game.in = InputState{};
|
||||
game.Step(FIXED_DT);
|
||||
watch.Observe(game, FIXED_DT);
|
||||
arenaWatch.Observe(game, FIXED_DT);
|
||||
clock += FIXED_DT;
|
||||
if (settle4 < 0.0f && CountLanes(game) >= 4) settle4 = clock;
|
||||
}
|
||||
|
||||
const bool p1 = (settle4 >= 0.0f && settle4 <= 1.5f);
|
||||
const bool p8 = (game.metrics.uptimeAvg >= 80.0f);
|
||||
allPass = allPass && p1 && p8;
|
||||
PrintVerdict("11.1 >=4/5 линий за <=1.5 с после остановки", p1,
|
||||
Fmt("settle=%.2f с, линий сейчас %d/5", double(settle4),
|
||||
CountLanes(game)));
|
||||
PrintVerdict("11.8 FIRE UPTIME в стоячем бою >= 80%", p8,
|
||||
Fmt("uptime=%.1f%%", double(game.metrics.uptimeAvg)));
|
||||
}
|
||||
|
||||
// === 11.6: узкий проход в 2 тайла =========================================
|
||||
// Коридор: стена y=13..15, проём x=30..31 -> движение вдоль -y, поперёк = x.
|
||||
{
|
||||
Game game;
|
||||
game.squad.meleeCount = kSpecSquadMelee;
|
||||
game.squad.pistolCount = kSpecSquadPistol;
|
||||
game.Init();
|
||||
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
||||
game.PlaceSquad({31.0f, 19.0f}, false);
|
||||
Watcher corridorWatch;
|
||||
|
||||
double lateralSum = 0.0; // усреднённый разброс поперёк/вдоль движения
|
||||
double longitudinalSum = 0.0;
|
||||
int maxInside = 0;
|
||||
// Считаются только выстрелы ВПЕРЁД, по ходу движения: именно там между
|
||||
// стрелком и целью может оказаться свой. Выстрел назад, в оставленную
|
||||
// позади комнату, к правилу «не стрелять сквозь своего» отношения не
|
||||
// имеет — впереди по такой линии никого из отряда нет по построению.
|
||||
int rearShotsInside = 0; // вперёд стреляет НЕ передний — вот это важно
|
||||
int frontShotsInside = 0;
|
||||
int backwardShots = 0; // для отчёта: сколько огня ушло назад
|
||||
int throughAllyShots = 0; // выстрелов, прошедших через тело союзника
|
||||
int samples = 0;
|
||||
|
||||
// Две фазы: сначала входим в проход, потом ВСТАЁМ и меряем.
|
||||
//
|
||||
// Мерить на ходу нельзя: пока игрок тянет курсор, тот всегда обгоняет
|
||||
// отряд (anchorSpeed 3.3 против moveSpeed 1.75), натяжение поводка
|
||||
// упирается в упор и включается спринт, а в спринте отряд по замыслу не
|
||||
// стреляет вовсе (Game::FireWeapons). Критерий 11.6 проверял бы пустоту.
|
||||
// Боевая ситуация в проходе — это именно «вошли и работаем»: тогда
|
||||
// передний держит линию, а задние упираются в его спину.
|
||||
// Меряем ВСЮ проводку через горловину, а не только стоянку в ней.
|
||||
// Проход глубиной три тайла, а бойцов пятеро — колонна физически длиннее
|
||||
// самого прохода и целиком внутрь него не помещается никогда. Поэтому
|
||||
// область замера это горловина ПЛЮС подходы с обеих сторон: именно на
|
||||
// этом отрезке видно, вытянулся отряд в нитку или пошёл шеренгой.
|
||||
//
|
||||
// Порог входа в шеренгу узкий по построению: сам проём шириной два тайла.
|
||||
const int totalSteps = int(26.0f / FIXED_DT);
|
||||
const int advanceSteps = int(16.0f / FIXED_DT);
|
||||
|
||||
for (int i = 0; i < totalSteps; ++i)
|
||||
{
|
||||
game.in = InputState{};
|
||||
// Ведём отряд сквозь проход и оставляем работать по северной кучке.
|
||||
if (i < advanceSteps) game.in.moveScreen = MoveInputToward(game, {31.0f, 9.0f});
|
||||
game.Step(FIXED_DT);
|
||||
watch.Observe(game, FIXED_DT);
|
||||
corridorWatch.Observe(game, FIXED_DT);
|
||||
|
||||
// Кто сейчас в створе прохода (горловина + подходы).
|
||||
int inside[NUM_AGENTS];
|
||||
int insideCount = 0;
|
||||
for (int k = 0; k < NUM_AGENTS; ++k)
|
||||
{
|
||||
const Vec2 p = game.squad.agents[k].pos;
|
||||
if (p.y > 9.0f && p.y < 19.0f && p.x > 28.0f && p.x < 34.0f)
|
||||
inside[insideCount++] = k;
|
||||
}
|
||||
if (insideCount < 2) continue;
|
||||
|
||||
++samples;
|
||||
maxInside = std::max(maxInside, insideCount);
|
||||
|
||||
float xMin = 1e9f, xMax = -1e9f, yMin = 1e9f, yMax = -1e9f;
|
||||
float frontY = 1e9f;
|
||||
for (int n = 0; n < insideCount; ++n)
|
||||
{
|
||||
const Vec2 p = game.squad.agents[inside[n]].pos;
|
||||
xMin = std::min(xMin, p.x); xMax = std::max(xMax, p.x);
|
||||
yMin = std::min(yMin, p.y); yMax = std::max(yMax, p.y);
|
||||
frontY = std::min(frontY, p.y);
|
||||
}
|
||||
// Колонна = вытянуты вдоль движения сильнее, чем поперёк.
|
||||
lateralSum += double(xMax - xMin);
|
||||
longitudinalSum += double(yMax - yMin);
|
||||
|
||||
for (int n = 0; n < insideCount; ++n)
|
||||
{
|
||||
const Agent& a = game.squad.agents[inside[n]];
|
||||
if (!a.fired) continue;
|
||||
const Vec2 tp = (a.currentTargetId >= 0) ? game.targets[a.currentTargetId].pos : Vec2{};
|
||||
|
||||
// НАСТОЯЩАЯ проверка правила, независимая от реализации: берём
|
||||
// отрезок реально сделанного выстрела и заново, своей геометрией,
|
||||
// смотрим, не задевает ли он тело союзника. Порог физический
|
||||
// (тело + полуширина трассы), без запаса safetyMargin: выстрел
|
||||
// впритирку, но мимо тела, правило не нарушает.
|
||||
const Vec2 muzzle = a.MuzzleTowards(tp);
|
||||
for (int k = 0; k < NUM_AGENTS; ++k)
|
||||
{
|
||||
if (k == inside[n]) continue;
|
||||
const Agent& ally = game.squad.agents[k];
|
||||
if (DistancePointToSegment(ally.pos, muzzle, tp) <
|
||||
ally.bodyRadius + g_tune.laneHalfWidth)
|
||||
++throughAllyShots;
|
||||
}
|
||||
|
||||
// Разбивка ниже — только для отчёта. Задний, стреляющий ПОД УГЛОМ
|
||||
// мимо переднего, ничего не нарушает: проход шириной 2 тайла даёт
|
||||
// законные диагональные линии, и это подтверждается проверкой выше.
|
||||
const bool forward = Dot(tp - a.pos, game.squad.forward) > 0.0f;
|
||||
const bool rear = a.pos.y > frontY + 0.5f;
|
||||
if (!forward) { ++backwardShots; continue; }
|
||||
if (rear) ++rearShotsInside;
|
||||
else ++frontShotsInside;
|
||||
}
|
||||
}
|
||||
|
||||
const double lateralAvg = (samples > 0) ? lateralSum / samples : 0.0;
|
||||
const double longAvg = (samples > 0) ? longitudinalSum / samples : 0.0;
|
||||
|
||||
// Что здесь проверяется на самом деле.
|
||||
//
|
||||
// «Задние не стреляют» — это СЛЕДСТВИЕ правила «не стрелять сквозь
|
||||
// своего», а не отдельное правило. В проходе шириной 2 тайла у заднего
|
||||
// бойца есть законные диагональные линии мимо переднего, и считать их
|
||||
// нарушением — значит требовать от отряда быть глупее, чем он есть.
|
||||
// Поэтому считаем не «кто стрелял», а инвариант: НИ ОДИН выстрел в
|
||||
// проходе не прошёл через тело союзника (перепроверено своей геометрией
|
||||
// по факту выстрела) и ни одного попадания по своим.
|
||||
//
|
||||
// Плюс форма: отряд обязан вытянуться в колонну, а не идти шеренгой.
|
||||
// Пороги с запасом: строгое неравенство на маленьких числах — монетка,
|
||||
// а не тест.
|
||||
const bool column = longAvg > lateralAvg * 1.5;
|
||||
const bool clean = (throughAllyShots == 0) && (game.metrics.friendlyHits == 0);
|
||||
const bool enough = (frontShotsInside + rearShotsInside) >= 4;
|
||||
const bool p6 = (samples > 0) && (maxInside >= 2) && column && clean && enough;
|
||||
allPass = allPass && p6;
|
||||
PrintVerdict("11.6 в проходе колонна, огонь не идёт сквозь своих", p6,
|
||||
Fmt("до %d агентов в проходе, поперёк %.2f / вдоль %.2f тайла, "
|
||||
"выстрелов вперёд передний/хвост %d/%d (назад %d), "
|
||||
"сквозь своего %d, FRIENDLY_HITS=%d",
|
||||
maxInside, lateralAvg, longAvg, frontShotsInside, rearShotsInside,
|
||||
backwardShots, throughAllyShots, game.metrics.friendlyHits));
|
||||
}
|
||||
|
||||
// === 11.7: ручной режим, назначенная цель =================================
|
||||
{
|
||||
Game game;
|
||||
game.squad.meleeCount = kSpecSquadMelee;
|
||||
game.squad.pistolCount = kSpecSquadPistol;
|
||||
game.Init();
|
||||
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
||||
game.PlaceSquad({28.0f, 23.5f}, true);
|
||||
|
||||
// Включаем ручной режим и наводим вектор прицела на кучку.
|
||||
InputState toggle;
|
||||
toggle.toggleAimMode = true;
|
||||
game.in = toggle;
|
||||
game.Step(FIXED_DT);
|
||||
|
||||
int stepsWithAssigned = 0;
|
||||
for (int i = 0; i < int(30.0f / FIXED_DT); ++i)
|
||||
{
|
||||
game.in = InputState{};
|
||||
// Медленно крутим прицел — Tab каждые 5 секунд.
|
||||
game.in.aimTurn = (i < int(3.0f / FIXED_DT)) ? 1.0f : 0.0f;
|
||||
game.in.cycleTarget = (i % int(5.0f / FIXED_DT)) == 0;
|
||||
game.Step(FIXED_DT);
|
||||
watch.Observe(game, FIXED_DT);
|
||||
if (game.aim.assignedTargetId >= 0) ++stepsWithAssigned;
|
||||
}
|
||||
|
||||
const bool p7 = (game.metrics.friendlyHits == 0) && (stepsWithAssigned > 0);
|
||||
allPass = allPass && p7;
|
||||
PrintVerdict("11.7 ручной режим не стреляет сквозь своих", p7,
|
||||
Fmt("FRIENDLY_HITS=%d, назначенная цель была %.0f%% времени",
|
||||
game.metrics.friendlyHits,
|
||||
100.0 * double(stepsWithAssigned) / (30.0 / FIXED_DT)));
|
||||
}
|
||||
|
||||
// === M7: ближний бой ======================================================
|
||||
// Смешанный отряд у кучки из пяти мишеней. Проверяется ровно то, что
|
||||
// требовалось от механики: клинок доходит и бьёт, никого из своих при этом
|
||||
// не задевает, и каждый класс живёт в СВОЁМ круге вокруг диска.
|
||||
{
|
||||
Game game;
|
||||
game.Init(); // состав по умолчанию: 2 клинка
|
||||
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
||||
game.PlaceSquad({28.0f, 23.5f}, true);
|
||||
|
||||
float maxMeleeFromAnchor = 0.0f;
|
||||
float maxRangedFromAnchor = 0.0f;
|
||||
|
||||
for (int i = 0; i < int(30.0f / FIXED_DT); ++i)
|
||||
{
|
||||
game.in = InputState{};
|
||||
game.Step(FIXED_DT);
|
||||
watch.Observe(game, FIXED_DT);
|
||||
|
||||
for (int k = 0; k < NUM_AGENTS; ++k)
|
||||
{
|
||||
const Agent& a = game.squad.agents[k];
|
||||
const float d = Distance(a.pos, game.squad.anchor);
|
||||
if (a.IsMelee()) maxMeleeFromAnchor = std::max(maxMeleeFromAnchor, d);
|
||||
else maxRangedFromAnchor = std::max(maxRangedFromAnchor, d);
|
||||
}
|
||||
}
|
||||
|
||||
const Metrics& m = game.metrics;
|
||||
// Порог круга с запасом на инерцию: цель может быть у самой границы,
|
||||
// и боец имеет право дотянуться до неё, а не тормозить по линейке.
|
||||
const float meleeLimit = g_tune.meleeCohesionRadius + 1.2f;
|
||||
const float rangedLimit = g_tune.cohesionRadius + 1.6f;
|
||||
|
||||
const bool pM1 = (m.meleeHits > 0) && (m.meleeSwings > 0);
|
||||
const bool pM2 = (m.friendlyHits == 0);
|
||||
const bool pM3 = (maxMeleeFromAnchor <= meleeLimit) &&
|
||||
(maxRangedFromAnchor <= rangedLimit) &&
|
||||
(maxRangedFromAnchor < maxMeleeFromAnchor);
|
||||
allPass = allPass && pM1 && pM2 && pM3;
|
||||
|
||||
PrintVerdict("M7.1 клинок доходит до цели и наносит урон", pM1,
|
||||
Fmt("взмахов %d, попаданий %d, оборвано своими %d, убито %d; "
|
||||
"связка %s=%d %s=%d %s=%d",
|
||||
m.meleeSwings, m.meleeHits, m.meleeAborts, m.targetsKilled,
|
||||
MELEE_COMBO[0].name, m.meleeComboSwings[0], MELEE_COMBO[1].name,
|
||||
m.meleeComboSwings[1], MELEE_COMBO[2].name, m.meleeComboSwings[2]));
|
||||
const bool pM4 = m.meleeMaxHitExcessDeg <= 12.0f;
|
||||
allPass = allPass && pM4;
|
||||
PrintVerdict("M7.4 лезвие не достаёт за пределы своей дуги", pM4,
|
||||
Fmt("максимум за сектором %.1f град (лимит 12)",
|
||||
double(m.meleeMaxHitExcessDeg)));
|
||||
PrintVerdict("M7.2 ближний бой не задевает своих", pM2,
|
||||
Fmt("FRIENDLY_HITS=%d при %d взмахах", m.friendlyHits, m.meleeSwings));
|
||||
PrintVerdict("M7.3 у каждого класса свой круг вокруг диска", pM3,
|
||||
Fmt("стрелки до %.2f (лимит %.2f), клинки до %.2f (лимит %.2f)",
|
||||
double(maxRangedFromAnchor), double(rangedLimit),
|
||||
double(maxMeleeFromAnchor), double(meleeLimit)));
|
||||
}
|
||||
|
||||
// === 11.2: три минуты непрерывного боя во всех режимах =====================
|
||||
{
|
||||
HarnessOptions opt;
|
||||
opt.seconds = 180.0f; // состав боевой: смешанный отряд, как в игре
|
||||
std::printf("--- прогон 180 с по маршруту (критерий 11.2) ---\n");
|
||||
const int rc = RunHeadless(opt);
|
||||
const bool p2 = (rc == 0);
|
||||
allPass = allPass && p2;
|
||||
PrintVerdict("11.2 FRIENDLY_HITS = 0 за 3 минуты", p2, p2 ? "0 попаданий" : "см. журнал");
|
||||
}
|
||||
|
||||
// === 11.4 + 11.5 ==========================================================
|
||||
// 11.4 меряется на открытой арене: в проходе 2 тайла разойтись физически
|
||||
// некуда, и это ровно тот геометрический тест-кейс, о котором говорит спек.
|
||||
const bool p4 = arenaWatch.maxResidence <= 0.4f;
|
||||
const bool p5 = watch.jitterViolations == 0;
|
||||
allPass = allPass && p4 && p5;
|
||||
PrintVerdict("11.4 агент уходит с чужой линии за ~0.3 с", p4,
|
||||
Fmt("на арене максимум %.2f с (по всем сценариям %.2f с)",
|
||||
double(arenaWatch.maxResidence), double(watch.maxResidence)));
|
||||
PrintVerdict("11.5 нет дёрганья postPos чаще commitTime", p5,
|
||||
Fmt("коммитов %d, нарушений %d, минимальный интервал %.2f с", watch.commits,
|
||||
watch.jitterViolations,
|
||||
double(watch.minCommitInterval > 1e8f ? 0.0f : watch.minCommitInterval)));
|
||||
|
||||
std::printf("\nИТОГ: %s\n", allPass ? "все проверяемые критерии PASS" : "есть FAIL");
|
||||
return allPass ? 0 : 1;
|
||||
}
|
||||
26
src/debug/harness.h
Normal file
26
src/debug/harness.h
Normal file
@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Прогоны симуляции без окна. Симуляция детерминирована и ничего не знает про
|
||||
// рендер, поэтому критерии приёмки (спек 11) можно мерить численно, а не на глаз.
|
||||
//
|
||||
// --headless <сек> маршрут по путевым точкам + метрики
|
||||
// --accept сценарии из раздела 11 с вердиктом PASS/FAIL
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "tuning.h"
|
||||
|
||||
struct HarnessOptions
|
||||
{
|
||||
float seconds = 60.0f;
|
||||
bool solver = true; // --no-solver
|
||||
bool laneAvoid = true; // --no-laneavoid
|
||||
bool probe = false; // --probe: подробности по агентам на стоянках
|
||||
// --melee N: сколько слотов держат клинок. Критерии стрельбы из спека 11
|
||||
// меряются на чистом отряде стрелков — иначе меняется смысл самих метрик.
|
||||
int meleeCount = DEFAULT_MELEE_COUNT;
|
||||
// --pistol N: сколько ПОСЛЕДНИХ слотов носят пистолет вместо карабина.
|
||||
// В сценариях спека тоже 0: дальность и разброс там должны быть штатными.
|
||||
int pistolCount = DEFAULT_PISTOL_COUNT;
|
||||
};
|
||||
|
||||
int RunHeadless(const HarnessOptions& opt);
|
||||
int RunAccept();
|
||||
103
src/debug/metrics.cpp
Normal file
103
src/debug/metrics.cpp
Normal file
@ -0,0 +1,103 @@
|
||||
#include "debug/metrics.h"
|
||||
|
||||
void Metrics::LogFriendlyHit(int shooter, int victim, Vec2 pos, bool victimAdvancing)
|
||||
{
|
||||
++friendlyHits;
|
||||
if (hitLogCount < HIT_LOG_MAX)
|
||||
hitLog[hitLogCount++] = FriendlyHitEvent{clock, shooter, victim, pos, victimAdvancing};
|
||||
}
|
||||
|
||||
void Metrics::Reset()
|
||||
{
|
||||
friendlyHits = 0;
|
||||
hitLogCount = 0;
|
||||
clock = 0.0f;
|
||||
targetsKilled = 0;
|
||||
shotsFired = 0;
|
||||
meleeSwings = 0;
|
||||
meleeHits = 0;
|
||||
meleeAborts = 0;
|
||||
for (int& v : meleeComboSwings) v = 0;
|
||||
meleeMaxHitExcessDeg = 0.0f;
|
||||
reloads = 0;
|
||||
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
laneCount[i] = 0;
|
||||
uptimePct[i] = 0.0f;
|
||||
for (int k = 0; k < UPTIME_SAMPLES; ++k) ring[i][k] = 0;
|
||||
}
|
||||
ringPos = 0;
|
||||
ringFill = 0;
|
||||
uptimeAvg = 0.0f;
|
||||
meleeUptimeAvg = 0.0f;
|
||||
|
||||
settleArmed = true; // старт — отряд уже стоит, замеряем первое расхождение
|
||||
settleClock = 0.0f;
|
||||
lastSettle = -1.0f;
|
||||
prevState = AgentState::HOLDING;
|
||||
simMs = 0.0f;
|
||||
renderMs = 0.0f;
|
||||
}
|
||||
|
||||
void Metrics::Sample(const Squad& squad, float dt)
|
||||
{
|
||||
clock += dt;
|
||||
|
||||
// --- FIRE UPTIME (скользящее окно) ---
|
||||
const int window = std::min(UPTIME_SAMPLES,
|
||||
std::max(1, int(g_tune.uptimeWindow / FIXED_DT)));
|
||||
if (ringPos >= window) ringPos = 0;
|
||||
|
||||
// «В бою» для стрелка — валидная линия огня, для клинка — цель в зоне удара.
|
||||
// Считаются они одинаково, а усредняются раздельно: смешивать доли времени
|
||||
// двух разных занятий в одно число бессмысленно.
|
||||
int readyNow = 0;
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
const Agent& a = squad.agents[i];
|
||||
const bool engaged = a.IsMelee() ? a.melee.inReach : a.hasLane;
|
||||
const unsigned char v = engaged ? 1u : 0u;
|
||||
|
||||
laneCount[i] -= ring[i][ringPos];
|
||||
ring[i][ringPos] = v;
|
||||
laneCount[i] += v;
|
||||
|
||||
// Для SETTLE TIME клинку достаточно ИМЕТЬ цель: он к ней ещё идёт,
|
||||
// но отряд уже разобрал обстановку, а именно это метрика и меряет.
|
||||
if (a.IsMelee() ? (a.melee.targetId >= 0) : a.hasLane) ++readyNow;
|
||||
}
|
||||
ringPos = (ringPos + 1) % window;
|
||||
ringFill = std::min(ringFill + 1, window);
|
||||
|
||||
float rangedSum = 0.0f;
|
||||
float meleeSum = 0.0f;
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
uptimePct[i] = (ringFill > 0) ? (100.0f * float(laneCount[i]) / float(ringFill)) : 0.0f;
|
||||
if (squad.agents[i].IsMelee()) meleeSum += uptimePct[i];
|
||||
else rangedSum += uptimePct[i];
|
||||
}
|
||||
const int ranged = squad.RangedCount();
|
||||
uptimeAvg = (ranged > 0) ? rangedSum / float(ranged) : 0.0f;
|
||||
meleeUptimeAvg = (squad.meleeCount > 0) ? meleeSum / float(squad.meleeCount) : 0.0f;
|
||||
|
||||
// --- SETTLE TIME ---
|
||||
if (squad.state == AgentState::HOLDING && prevState == AgentState::ADVANCING)
|
||||
{
|
||||
settleArmed = true;
|
||||
settleClock = 0.0f;
|
||||
}
|
||||
if (squad.state == AgentState::ADVANCING) settleArmed = false;
|
||||
|
||||
if (settleArmed)
|
||||
{
|
||||
settleClock += dt;
|
||||
if (float(readyNow) >= g_tune.settleQuorum)
|
||||
{
|
||||
lastSettle = settleClock;
|
||||
settleArmed = false;
|
||||
}
|
||||
}
|
||||
prevState = squad.state;
|
||||
}
|
||||
78
src/debug/metrics.h
Normal file
78
src/debug/metrics.h
Normal file
@ -0,0 +1,78 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Метрики корректности (спек 7):
|
||||
// FRIENDLY_HITS — накопительный счётчик попаданий по своим (главная метрика);
|
||||
// FIRE UPTIME — доля времени с валидной линией за последние 10 сек;
|
||||
// SETTLE TIME — от перехода в HOLDING до момента, когда >=4 агента в бою.
|
||||
//
|
||||
// С появлением ближнего боя два уточнения, иначе цифры врут:
|
||||
// * FIRE UPTIME усредняется ТОЛЬКО по стрелкам. У бойца с клинком линии огня
|
||||
// не бывает по определению, и его ноль занижал бы метрику стрельбы;
|
||||
// * «в бою» (для SETTLE TIME) = стрелок при линии ИЛИ клинок при цели.
|
||||
// Отдельно считается MELEE UPTIME — доля времени в зоне удара.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "sim/squad.h"
|
||||
|
||||
constexpr int UPTIME_SAMPLES = 600; // 10 сек при 60 Гц
|
||||
|
||||
// Журнал попаданий по своим: без него FRIENDLY_HITS != 0 нечем объяснить.
|
||||
struct FriendlyHitEvent
|
||||
{
|
||||
float time = 0.0f;
|
||||
int shooter = -1;
|
||||
int victim = -1;
|
||||
Vec2 pos{};
|
||||
bool victimAdvancing = false;
|
||||
};
|
||||
|
||||
constexpr int HIT_LOG_MAX = 16;
|
||||
|
||||
struct Metrics
|
||||
{
|
||||
int friendlyHits = 0;
|
||||
int targetsKilled = 0;
|
||||
int shotsFired = 0;
|
||||
|
||||
// ближний бой
|
||||
int meleeSwings = 0; // взмахов начато
|
||||
int meleeHits = 0; // попаданий лезвием
|
||||
int meleeAborts = 0; // взмахов оборвано из-за своего в дуге
|
||||
// Сколько взмахов пришлось на каждый удар связки. Если серия не
|
||||
// прокручивается, здесь будет ноль во всех ячейках кроме первой.
|
||||
int meleeComboSwings[MELEE_COMBO_STEPS] = {};
|
||||
// На сколько градусов попадание лезвия вышло за РАЗРЕШЁННЫЙ сектор этого
|
||||
// взмаха (полусектор + полуширина полосы). Должно быть ~0: если лезвие
|
||||
// достаёт за спину, здесь вырастет десятки градусов. Ровно этим и выглядел
|
||||
// баг «бьёт на 360».
|
||||
float meleeMaxHitExcessDeg = 0.0f;
|
||||
int reloads = 0; // сколько раз стрелки уходили в перезарядку
|
||||
|
||||
FriendlyHitEvent hitLog[HIT_LOG_MAX];
|
||||
int hitLogCount = 0;
|
||||
float clock = 0.0f; // время симуляции, для журнала
|
||||
|
||||
void LogFriendlyHit(int shooter, int victim, Vec2 pos, bool victimAdvancing);
|
||||
|
||||
// FIRE UPTIME: кольцевой буфер + инкрементальные счётчики
|
||||
unsigned char ring[NUM_AGENTS][UPTIME_SAMPLES] = {};
|
||||
int ringPos = 0;
|
||||
int ringFill = 0;
|
||||
int laneCount[NUM_AGENTS] = {};
|
||||
float uptimePct[NUM_AGENTS] = {};
|
||||
float uptimeAvg = 0.0f; // только по стрелкам
|
||||
float meleeUptimeAvg = 0.0f; // только по клинкам: доля времени в зоне удара
|
||||
|
||||
// SETTLE TIME
|
||||
bool settleArmed = false;
|
||||
float settleClock = 0.0f;
|
||||
float lastSettle = -1.0f;
|
||||
|
||||
// производительность (заполняется главным циклом)
|
||||
float simMs = 0.0f;
|
||||
float renderMs = 0.0f;
|
||||
|
||||
AgentState prevState = AgentState::HOLDING;
|
||||
|
||||
void Reset();
|
||||
void Sample(const Squad& squad, float dt);
|
||||
};
|
||||
307
src/debug/overlay.cpp
Normal file
307
src/debug/overlay.cpp
Normal file
@ -0,0 +1,307 @@
|
||||
#include "debug/overlay.h"
|
||||
|
||||
#include <raylib.h>
|
||||
|
||||
#include "game.h"
|
||||
#include "render/view.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Мир -> координаты окна (кадр уже растянут в WINDOW_SCALE раз).
|
||||
inline Vector2 ToWindow(const View& v, Vec2 world)
|
||||
{
|
||||
const Vec2 s = iso::WorldToScreen(world);
|
||||
return Vector2{(s.x + float(v.scene.ox)) * float(WINDOW_SCALE),
|
||||
(s.y + float(v.scene.oy)) * float(WINDOW_SCALE)};
|
||||
}
|
||||
|
||||
void Ring(const View& g, Vec2 center, float radiusWorld, Color c, int segments = 48)
|
||||
{
|
||||
Vector2 prev{};
|
||||
for (int i = 0; i <= segments; ++i)
|
||||
{
|
||||
const float a = float(i) * (TWO_PI_F / float(segments));
|
||||
const Vector2 p = ToWindow(g, center + FromAngle(a) * radiusWorld);
|
||||
if (i > 0) DrawLineV(prev, p, c);
|
||||
prev = p;
|
||||
}
|
||||
}
|
||||
|
||||
const char* StateName(AgentState s)
|
||||
{
|
||||
return (s == AgentState::ADVANCING) ? "ADVANCING" : "HOLDING";
|
||||
}
|
||||
|
||||
const char* MeleePhase(MeleeState s)
|
||||
{
|
||||
switch (s)
|
||||
{
|
||||
case MeleeState::WINDUP: return "wind";
|
||||
case MeleeState::STRIKE: return "HIT";
|
||||
case MeleeState::RECOVER: return "rec";
|
||||
default: return "-";
|
||||
}
|
||||
}
|
||||
|
||||
Color ScaleAlpha(Color c, float k)
|
||||
{
|
||||
c.a = (unsigned char)Clampf(float(c.a) * k, 0.0f, 255.0f);
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Overlay::HandleKeys()
|
||||
{
|
||||
if (IsKeyPressed(KEY_F1)) show = !show;
|
||||
if (IsKeyPressed(KEY_F2)) showLanes = !showLanes;
|
||||
if (IsKeyPressed(KEY_F3)) showCandidates = !showCandidates;
|
||||
if (IsKeyPressed(KEY_F4)) showMetrics = !showMetrics;
|
||||
|
||||
if (IsKeyPressed(KEY_LEFT_BRACKET)) tuneIndex = (tuneIndex + TUNABLE_COUNT - 1) % TUNABLE_COUNT;
|
||||
if (IsKeyPressed(KEY_RIGHT_BRACKET)) tuneIndex = (tuneIndex + 1) % TUNABLE_COUNT;
|
||||
|
||||
const TunableParam& p = TUNABLES[tuneIndex];
|
||||
float& value = g_tune.*(p.member);
|
||||
if (IsKeyPressed(KEY_MINUS) || IsKeyPressedRepeat(KEY_MINUS))
|
||||
value = Clampf(value - p.step, p.minV, p.maxV);
|
||||
if (IsKeyPressed(KEY_EQUAL) || IsKeyPressedRepeat(KEY_EQUAL))
|
||||
value = Clampf(value + p.step, p.minV, p.maxV);
|
||||
}
|
||||
|
||||
void Overlay::Draw(const Game& game, const View& view) const
|
||||
{
|
||||
if (!show) return;
|
||||
|
||||
const Squad& squad = game.squad;
|
||||
|
||||
// --- якорь, круги когезии, слоты строя ---
|
||||
Ring(view, squad.anchor, g_tune.cohesionRadius, Color{60, 90, 120, 160});
|
||||
// Свой, больший круг бойцов ближнего боя — их отпускают дальше.
|
||||
if (squad.meleeCount > 0)
|
||||
Ring(view, squad.anchor, g_tune.meleeCohesionRadius, Color{150, 100, 60, 110});
|
||||
// Поводок курсора, обе границы упругой модели: сплошной круг — мягкая
|
||||
// граница (дальше курсор тяжелеет), тусклый внешний — жёсткий предел.
|
||||
// По зазору между ними видно натяжение, то есть скоро ли отряд побежит.
|
||||
Ring(view, squad.Centroid(), g_tune.anchorLeash,
|
||||
squad.sprinting ? Color{255, 160, 70, 130} : Color{70, 110, 150, 110});
|
||||
Ring(view, squad.Centroid(), std::max(g_tune.anchorLeash + 0.2f, g_tune.anchorLeashMax),
|
||||
Color{90, 70, 110, 70});
|
||||
DrawCircleV(ToWindow(view, squad.anchor), 4.0f,
|
||||
squad.sprinting ? Color{255, 190, 120, 230} : Color{120, 200, 255, 220});
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
const Vector2 p = ToWindow(view, squad.SlotWorld(i));
|
||||
DrawCircleLinesV(p, 5.0f, Color{80, 120, 160, 140});
|
||||
}
|
||||
|
||||
// --- сектор ближнего боя ---
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
const Agent& a = squad.agents[i];
|
||||
if (!a.IsMelee()) continue;
|
||||
|
||||
const MeleeWeapon& m = a.melee;
|
||||
Color c;
|
||||
switch (m.state)
|
||||
{
|
||||
case MeleeState::WINDUP: c = Color{255, 200, 80, 220}; break; // замах
|
||||
case MeleeState::STRIKE: c = Color{255, 255, 255, 240}; break; // удар
|
||||
case MeleeState::RECOVER: c = Color{120, 120, 140, 160}; break;
|
||||
default:
|
||||
// В покое цвет говорит, почему не бьём: жёлтый — свой в дуге.
|
||||
c = m.arcBlockedByAlly ? Color{240, 210, 60, 180}
|
||||
: Color{90, 150, 200, 110};
|
||||
break;
|
||||
}
|
||||
|
||||
Ring(view, a.pos, m.reach, ScaleAlpha(c, 0.55f), 24);
|
||||
|
||||
const float axis = m.Committed() ? m.dir : a.facing;
|
||||
const float half = m.HalfArc();
|
||||
for (int s = -1; s <= 1; s += 2)
|
||||
{
|
||||
const Vec2 e = a.pos + FromAngle(axis + half * float(s)) * m.reach;
|
||||
DrawLineV(ToWindow(view, a.pos), ToWindow(view, e), c);
|
||||
}
|
||||
if (m.state == MeleeState::STRIKE)
|
||||
{
|
||||
const Vec2 blade = a.pos + FromAngle(m.BladeAngleAt(m.Progress())) * m.reach;
|
||||
DrawLineEx(ToWindow(view, a.pos), ToWindow(view, blade), 2.0f, c);
|
||||
}
|
||||
}
|
||||
|
||||
// --- линии огня (F2) ---
|
||||
if (showLanes)
|
||||
{
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
const Agent& a = squad.agents[i];
|
||||
if (a.currentTargetId < 0 && !a.targetBlockedByAlly && !a.targetBlockedByWall) continue;
|
||||
|
||||
Color c;
|
||||
if (a.hasLane) c = Color{60, 230, 90, 220}; // чистая, стреляет
|
||||
else if (a.targetBlockedByWall) c = Color{230, 70, 60, 200}; // перекрыта стеной
|
||||
else c = Color{240, 210, 60, 200}; // перекрыта своим
|
||||
|
||||
const Vec2 end = a.laneEnd;
|
||||
DrawLineEx(ToWindow(view, a.MuzzleTowards(end)), ToWindow(view, end), 2.0f, c);
|
||||
}
|
||||
}
|
||||
|
||||
// --- кандидаты решателя (F3) ---
|
||||
if (showCandidates)
|
||||
{
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
if (!game.lastSolveValid[i]) continue;
|
||||
const SolveResult& r = game.lastSolve[i];
|
||||
|
||||
float lo = 1e9f, hi = -1e9f;
|
||||
for (int k = 0; k < r.count; ++k)
|
||||
{
|
||||
if (!r.valid[k]) continue;
|
||||
lo = std::min(lo, r.scores[k]);
|
||||
hi = std::max(hi, r.scores[k]);
|
||||
}
|
||||
const float span = std::max(1e-3f, hi - lo);
|
||||
|
||||
for (int k = 0; k < r.count; ++k)
|
||||
{
|
||||
const Vector2 p = ToWindow(view, r.candidates[k]);
|
||||
if (!r.valid[k])
|
||||
{
|
||||
DrawCircleV(p, 2.0f, Color{80, 40, 40, 140}); // отбракован
|
||||
continue;
|
||||
}
|
||||
const float t = (r.scores[k] - lo) / span; // синий -> белый
|
||||
const unsigned char v = (unsigned char)(60 + 195 * t);
|
||||
DrawCircleV(p, 3.0f, Color{v, v, 255, 200});
|
||||
}
|
||||
if (r.bestIndex > 0)
|
||||
DrawCircleLinesV(ToWindow(view, r.candidates[r.bestIndex]), 8.0f,
|
||||
Color{255, 255, 255, 230});
|
||||
}
|
||||
}
|
||||
|
||||
// --- диски обзора (F6) ---
|
||||
if (view.showSight)
|
||||
{
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
const Agent& a = squad.agents[i];
|
||||
Ring(view, a.pos, g_tune.sightRadius, Color{60, 130, 90, 70});
|
||||
Ring(view, a.pos, g_tune.sightPersonalRadius, Color{90, 170, 120, 110});
|
||||
|
||||
// Границы конуса взгляда.
|
||||
const float half = g_tune.sightConeDeg * 0.5f * DEG2RAD_F;
|
||||
for (int sgn = -1; sgn <= 1; sgn += 2)
|
||||
{
|
||||
const Vec2 e = a.pos + FromAngle(a.facing + half * float(sgn)) * g_tune.sightRadius;
|
||||
DrawLineV(ToWindow(view, a.pos), ToWindow(view, e), Color{90, 200, 140, 110});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- подписи над агентами ---
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
const Agent& a = squad.agents[i];
|
||||
const Vector2 p = ToWindow(view, a.pos);
|
||||
if (a.IsMelee())
|
||||
DrawText(TextFormat("%dM %c t%d %s", i, a.state == AgentState::ADVANCING ? 'A' : 'H',
|
||||
a.currentTargetId, MeleePhase(a.melee.state)),
|
||||
int(p.x) - 18, int(p.y) - 62, 12, Color{255, 210, 170, 210});
|
||||
else
|
||||
{
|
||||
// Патроны и перезарядка — то, чего не видно по силуэту, но от чего
|
||||
// напрямую зависит, почему стрелок с чистой линией молчит.
|
||||
const char* tag = (a.gunId == FirearmId::PISTOL) ? "P" : "R";
|
||||
const Color col = a.gun.Reloading() ? Color{255, 170, 90, 230}
|
||||
: Color{220, 220, 220, 210};
|
||||
if (a.gun.Reloading())
|
||||
DrawText(TextFormat("%d%s %c t%d RELOAD %.1f", i, tag,
|
||||
a.state == AgentState::ADVANCING ? 'A' : 'H',
|
||||
a.currentTargetId, double(a.gun.reloadTimer)),
|
||||
int(p.x) - 18, int(p.y) - 62, 12, col);
|
||||
else
|
||||
DrawText(TextFormat("%d%s %c t%d %d/%d", i, tag,
|
||||
a.state == AgentState::ADVANCING ? 'A' : 'H',
|
||||
a.currentTargetId, a.gun.ammo, a.gun.def.magazine),
|
||||
int(p.x) - 18, int(p.y) - 62, 12, col);
|
||||
}
|
||||
|
||||
// выбранная огневая позиция
|
||||
if (a.state == AgentState::HOLDING || a.IsMelee())
|
||||
DrawCircleLinesV(ToWindow(view, a.postPos), 4.0f, Color{160, 160, 255, 140});
|
||||
}
|
||||
|
||||
// --- назначенная цель / вектор прицела (6.8) ---
|
||||
if (game.aim.manual)
|
||||
{
|
||||
const Vec2 from = squad.anchor;
|
||||
const Vec2 to = from + FromAngle(game.aim.dir) * g_tune.weaponRange;
|
||||
DrawLineEx(ToWindow(view, from), ToWindow(view, to), 1.0f, Color{255, 255, 255, 90});
|
||||
|
||||
const float cone = g_tune.aimConeDeg * DEG2RAD_F;
|
||||
for (int s = -1; s <= 1; s += 2)
|
||||
{
|
||||
const Vec2 e = from + FromAngle(game.aim.dir + cone * float(s)) * g_tune.weaponRange;
|
||||
DrawLineEx(ToWindow(view, from), ToWindow(view, e), 1.0f, Color{255, 255, 255, 50});
|
||||
}
|
||||
if (game.aim.assignedTargetId >= 0)
|
||||
DrawCircleLinesV(ToWindow(view, game.targets[game.aim.assignedTargetId].pos), 12.0f,
|
||||
Color{255, 120, 120, 230});
|
||||
}
|
||||
|
||||
// --- текстовая панель ---
|
||||
int y = 8;
|
||||
auto line = [&](const char* text, Color c = RAYWHITE) {
|
||||
DrawText(text, 8, y, 18, c);
|
||||
y += 20;
|
||||
};
|
||||
|
||||
line(TextFormat("%s %s aim:%s fps %d", StateName(squad.state),
|
||||
FormationName(squad.formation), game.aim.manual ? "MANUAL" : "AUTO", GetFPS()));
|
||||
|
||||
// Натяжение поводка: главная новая величина управления. Видно, почему отряд
|
||||
// ускорился и почему перестал стрелять.
|
||||
line(TextFormat("PULL %.2f %s speed %.2f form %.2fx%.2f", double(squad.urgency),
|
||||
squad.sprinting ? "SPRINT" : " ", double(squad.MoveSpeed()),
|
||||
double(squad.formSpacing), double(squad.formStretch)),
|
||||
squad.sprinting ? Color{255, 180, 90, 255} : Color{170, 200, 230, 255});
|
||||
|
||||
if (showMetrics)
|
||||
{
|
||||
const Metrics& m = game.metrics;
|
||||
line(TextFormat("FRIENDLY_HITS %d", m.friendlyHits),
|
||||
m.friendlyHits == 0 ? Color{120, 240, 120, 255} : Color{255, 90, 90, 255});
|
||||
line(TextFormat("FIRE UPTIME %.0f%% [%.0f %.0f %.0f %.0f %.0f]", double(m.uptimeAvg),
|
||||
double(m.uptimePct[0]), double(m.uptimePct[1]), double(m.uptimePct[2]),
|
||||
double(m.uptimePct[3]), double(m.uptimePct[4])));
|
||||
line(TextFormat("SETTLE %.2f s shots %d kills %d reloads %d", double(m.lastSettle),
|
||||
m.shotsFired, m.targetsKilled, m.reloads));
|
||||
if (squad.meleeCount > 0)
|
||||
line(TextFormat("MELEE reach %.0f%% swings %d hits %d aborted %d",
|
||||
double(m.meleeUptimeAvg), m.meleeSwings, m.meleeHits, m.meleeAborts),
|
||||
m.meleeAborts == 0 ? Color{255, 200, 150, 255} : Color{240, 210, 60, 255});
|
||||
line(TextFormat("sim %.2f render %.2f ms/frame", double(m.simMs), double(m.renderMs)),
|
||||
Color{180, 180, 180, 255});
|
||||
// Кровь: сколько тайлов пола уже запечено. Растёт и не убывает — это
|
||||
// и есть «декали навсегда», и видно, во что они обходятся по памяти.
|
||||
// Только ASCII: у встроенного шрифта raylib нет кириллицы, она вылезает
|
||||
// вопросительными знаками. Весь оверлей по этой причине на латинице.
|
||||
line(TextFormat("BLOOD %d splats, %d tiles baked (limit %d)", view.blood.splats,
|
||||
int(view.blood.stained.size()), BLOOD_TILE_BUDGET),
|
||||
Color{200, 130, 130, 255});
|
||||
}
|
||||
|
||||
const TunableParam& p = TUNABLES[tuneIndex];
|
||||
line(TextFormat("[ %s ] = %.3f ( [ ] select, - = adjust )", p.name,
|
||||
double(g_tune.*(p.member))),
|
||||
Color{255, 210, 120, 255});
|
||||
|
||||
DrawText("F1 overlay F2 lanes F3 cand F4 metrics F5 horror F6 sight R reset Esc", 8,
|
||||
WINDOW_H - 24, 16, Color{160, 160, 160, 200});
|
||||
}
|
||||
20
src/debug/overlay.h
Normal file
20
src/debug/overlay.h
Normal file
@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Debug-оверлей (спек 7). Рисуется примитивами raylib ПОВЕРХ уже
|
||||
// отмасштабированного кадра — это единственное исключение из правила
|
||||
// «вся сцена софтверная», и оно dev-only (F1 выключает).
|
||||
// -----------------------------------------------------------------------------
|
||||
struct Game;
|
||||
struct View;
|
||||
|
||||
struct Overlay
|
||||
{
|
||||
bool show = true; // F1
|
||||
bool showLanes = true; // F2
|
||||
bool showCandidates = false; // F3
|
||||
bool showMetrics = true; // F4
|
||||
int tuneIndex = 0; // [ / ]
|
||||
|
||||
void HandleKeys();
|
||||
void Draw(const Game& game, const View& view) const;
|
||||
};
|
||||
231
src/ecs/ecs.h
Normal file
231
src/ecs/ecs.h
Normal file
@ -0,0 +1,231 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Минимальная ECS: сущность — это идентификатор, данные лежат в типизированных
|
||||
// плотных пулах, системы — свободные функции над пулами.
|
||||
//
|
||||
// Зачем она здесь, если прототип и без неё работал:
|
||||
// * оружие стало отдельной СУЩНОСТЬЮ со своим набором статов (пистолет,
|
||||
// карабин, клинок). Боец ссылается на неё, а не носит числа в себе —
|
||||
// значит ствол можно подменить, не трогая ни бойца, ни правила боя;
|
||||
// * мишени, пули и бойцы перестали быть тремя разными контейнерами в Game
|
||||
// и стали тремя пулами одного мира. Добавить четвёртый вид сущности —
|
||||
// это новый компонент, а не новое поле в Game и новый цикл в Step.
|
||||
//
|
||||
// Чего здесь НЕТ и почему:
|
||||
// * архетипов и запросов вида View<A, B, C>. Сущностей в прототипе десятки,
|
||||
// а не десятки тысяч; выигрыш от архетипов ушёл бы в сложность, а цена
|
||||
// промаха кеша на 5 агентах равна нулю;
|
||||
// * реакции на изменение компонентов, планировщика систем, рефлексии.
|
||||
//
|
||||
// Гранулярность компонентов сознательно КРУПНАЯ (Agent, Target, Bullet целиком,
|
||||
// а не Position+Velocity+Body по отдельности). Причина в docs/12-ecs.md:
|
||||
// правила боя из спека читают состояние агента целиком и в одном порядке,
|
||||
// и дробление их только запутало бы, не дав ни строчки полезного обобщения.
|
||||
//
|
||||
// Детерминизм: удаление — swap-remove, порядок пулов зависит только от истории
|
||||
// вызовов. Одинаковая последовательность операций даёт одинаковый мир, поэтому
|
||||
// --headless по-прежнему воспроизводим.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace ecs
|
||||
{
|
||||
|
||||
// Идентификатор: младшие 24 бита — индекс слота, старшие 8 — поколение.
|
||||
// Поколение отличает «ту же ячейку, но уже другую сущность» от живой ссылки.
|
||||
using Entity = uint32_t;
|
||||
|
||||
constexpr Entity NO_ENTITY = 0xFFFFFFFFu;
|
||||
constexpr uint32_t ENTITY_INDEX_BITS = 24;
|
||||
constexpr uint32_t ENTITY_INDEX_MASK = (1u << ENTITY_INDEX_BITS) - 1u;
|
||||
|
||||
inline uint32_t IndexOf(Entity e) { return e & ENTITY_INDEX_MASK; }
|
||||
inline uint32_t GenOf(Entity e) { return e >> ENTITY_INDEX_BITS; }
|
||||
inline Entity MakeEntity(uint32_t index, uint32_t gen)
|
||||
{
|
||||
return (index & ENTITY_INDEX_MASK) | (gen << ENTITY_INDEX_BITS);
|
||||
}
|
||||
|
||||
// --- пулы --------------------------------------------------------------------
|
||||
|
||||
struct IPool
|
||||
{
|
||||
virtual ~IPool() = default;
|
||||
virtual void Remove(Entity e) = 0;
|
||||
virtual void Clear() = 0;
|
||||
};
|
||||
|
||||
// Плотный массив компонентов + разрежённая таблица «индекс сущности -> место».
|
||||
// data остаётся сплошным, поэтому его можно отдавать наружу как обычный массив
|
||||
// (именно так SolverContext получает Agent* и ничего не знает про ECS).
|
||||
template <class T>
|
||||
struct Pool final : IPool
|
||||
{
|
||||
std::vector<T> data;
|
||||
std::vector<Entity> owner; // кому принадлежит data[i]
|
||||
std::vector<int32_t> sparse; // индекс сущности -> позиция в data, -1 = нет
|
||||
|
||||
bool Has(Entity e) const
|
||||
{
|
||||
const uint32_t i = IndexOf(e);
|
||||
return i < sparse.size() && sparse[i] >= 0;
|
||||
}
|
||||
|
||||
T* Find(Entity e)
|
||||
{
|
||||
const uint32_t i = IndexOf(e);
|
||||
if (i >= sparse.size() || sparse[i] < 0) return nullptr;
|
||||
return &data[size_t(sparse[i])];
|
||||
}
|
||||
|
||||
const T* Find(Entity e) const
|
||||
{
|
||||
const uint32_t i = IndexOf(e);
|
||||
if (i >= sparse.size() || sparse[i] < 0) return nullptr;
|
||||
return &data[size_t(sparse[i])];
|
||||
}
|
||||
|
||||
T& Add(Entity e, const T& value)
|
||||
{
|
||||
const uint32_t i = IndexOf(e);
|
||||
if (i >= sparse.size()) sparse.resize(size_t(i) + 1, -1);
|
||||
if (sparse[i] >= 0)
|
||||
{
|
||||
data[size_t(sparse[i])] = value;
|
||||
return data[size_t(sparse[i])];
|
||||
}
|
||||
sparse[i] = int32_t(data.size());
|
||||
data.push_back(value);
|
||||
owner.push_back(e);
|
||||
return data.back();
|
||||
}
|
||||
|
||||
void Remove(Entity e) override
|
||||
{
|
||||
const uint32_t i = IndexOf(e);
|
||||
if (i >= sparse.size() || sparse[i] < 0) return;
|
||||
|
||||
const size_t slot = size_t(sparse[i]);
|
||||
const size_t last = data.size() - 1;
|
||||
if (slot != last)
|
||||
{
|
||||
data[slot] = data[last];
|
||||
owner[slot] = owner[last];
|
||||
sparse[IndexOf(owner[slot])] = int32_t(slot);
|
||||
}
|
||||
data.pop_back();
|
||||
owner.pop_back();
|
||||
sparse[i] = -1;
|
||||
}
|
||||
|
||||
void Clear() override
|
||||
{
|
||||
data.clear();
|
||||
owner.clear();
|
||||
for (int32_t& s : sparse) s = -1;
|
||||
}
|
||||
|
||||
int Count() const { return int(data.size()); }
|
||||
};
|
||||
|
||||
// --- идентификаторы типов ----------------------------------------------------
|
||||
|
||||
inline uint32_t NextTypeId()
|
||||
{
|
||||
static uint32_t next = 0;
|
||||
return next++;
|
||||
}
|
||||
|
||||
template <class T>
|
||||
inline uint32_t TypeId()
|
||||
{
|
||||
static const uint32_t id = NextTypeId();
|
||||
return id;
|
||||
}
|
||||
|
||||
// --- мир ---------------------------------------------------------------------
|
||||
|
||||
struct World
|
||||
{
|
||||
// Создание/уничтожение сущностей.
|
||||
Entity Create()
|
||||
{
|
||||
if (!freeSlots.empty())
|
||||
{
|
||||
const uint32_t i = freeSlots.back();
|
||||
freeSlots.pop_back();
|
||||
alive[i] = 1;
|
||||
return MakeEntity(i, generation[i]);
|
||||
}
|
||||
const uint32_t i = uint32_t(alive.size());
|
||||
alive.push_back(1);
|
||||
generation.push_back(0);
|
||||
return MakeEntity(i, 0);
|
||||
}
|
||||
|
||||
bool Alive(Entity e) const
|
||||
{
|
||||
const uint32_t i = IndexOf(e);
|
||||
return i < alive.size() && alive[i] != 0 && generation[i] == GenOf(e);
|
||||
}
|
||||
|
||||
void Destroy(Entity e)
|
||||
{
|
||||
if (!Alive(e)) return;
|
||||
const uint32_t i = IndexOf(e);
|
||||
for (auto& p : pools)
|
||||
if (p) p->Remove(e);
|
||||
alive[i] = 0;
|
||||
generation[i] = uint8_t((generation[i] + 1u) & 0xFFu);
|
||||
freeSlots.push_back(i);
|
||||
}
|
||||
|
||||
// Пул компонента. Создаётся при первом обращении и живёт до смерти мира:
|
||||
// Clear() чистит СОДЕРЖИМОЕ, но не сами пулы, поэтому ссылки на data
|
||||
// (например Game::targets) переживают Reset уровня.
|
||||
template <class T>
|
||||
Pool<T>& Store()
|
||||
{
|
||||
const uint32_t id = TypeId<T>();
|
||||
if (id >= pools.size()) pools.resize(size_t(id) + 1);
|
||||
if (!pools[id]) pools[id] = std::make_unique<Pool<T>>();
|
||||
return *static_cast<Pool<T>*>(pools[id].get());
|
||||
}
|
||||
|
||||
template <class T>
|
||||
const Pool<T>& Store() const
|
||||
{
|
||||
return const_cast<World*>(this)->Store<T>();
|
||||
}
|
||||
|
||||
template <class T>
|
||||
T& Add(Entity e, const T& value) { return Store<T>().Add(e, value); }
|
||||
|
||||
template <class T>
|
||||
T* Get(Entity e) { return Store<T>().Find(e); }
|
||||
|
||||
template <class T>
|
||||
const T* Get(Entity e) const { return Store<T>().Find(e); }
|
||||
|
||||
template <class T>
|
||||
std::vector<T>& All() { return Store<T>().data; }
|
||||
|
||||
void Clear()
|
||||
{
|
||||
for (auto& p : pools)
|
||||
if (p) p->Clear();
|
||||
alive.clear();
|
||||
generation.clear();
|
||||
freeSlots.clear();
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<uint8_t> alive;
|
||||
std::vector<uint8_t> generation;
|
||||
std::vector<uint32_t> freeSlots;
|
||||
std::vector<std::unique_ptr<IPool>> pools;
|
||||
};
|
||||
|
||||
} // namespace ecs
|
||||
557
src/game.cpp
Normal file
557
src/game.cpp
Normal file
@ -0,0 +1,557 @@
|
||||
#include "game.h"
|
||||
|
||||
// Перевод экранных осей ввода в мировые — единственное место,
|
||||
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
|
||||
#include "render/iso.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Снять с мира все пули разом. Пуля — сущность, поэтому просто очистить вектор
|
||||
// нельзя: остались бы живые идентификаторы без компонентов.
|
||||
void ClearBullets(ecs::World& world)
|
||||
{
|
||||
std::vector<Bullet>& bullets = world.All<Bullet>();
|
||||
while (!bullets.empty()) world.Destroy(bullets.back().self);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Game::Game()
|
||||
: targets(world.Store<Target>().data),
|
||||
bullets(world.Store<Bullet>().data)
|
||||
{
|
||||
}
|
||||
|
||||
void Game::Init()
|
||||
{
|
||||
sprites.Generate();
|
||||
Reset();
|
||||
}
|
||||
|
||||
void Game::Reset()
|
||||
{
|
||||
map.Generate();
|
||||
world.Clear(); // бойцы, оружие, мишени, пули — всё разом
|
||||
squad.Init(world, {SQUAD_START_X, SQUAD_START_Y});
|
||||
SpawnTargets(world);
|
||||
lanes.Clear();
|
||||
metrics.Reset();
|
||||
events.Clear();
|
||||
aim = AimState{};
|
||||
for (bool& v : lastSolveValid) v = false;
|
||||
simTime = 0.0f;
|
||||
}
|
||||
|
||||
void Game::PlaceSquad(Vec2 at, bool clumped)
|
||||
{
|
||||
squad.anchor = at;
|
||||
squad.anchorPrev = at;
|
||||
squad.anchorVel = {};
|
||||
squad.state = AgentState::HOLDING;
|
||||
squad.idleTimer = g_tune.holdDelay;
|
||||
squad.holdElapsed = 0.0f;
|
||||
squad.urgency = 0.0f;
|
||||
squad.sprinting = false;
|
||||
squad.formSpacing = 1.0f;
|
||||
squad.formStretch = 1.0f;
|
||||
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
Agent& a = squad.agents[i];
|
||||
a.pos = clumped ? at : squad.SlotWorld(i);
|
||||
map.ResolveCircle(a.pos, a.bodyRadius);
|
||||
a.prevPos = a.pos;
|
||||
a.postPos = a.pos;
|
||||
a.vel = {};
|
||||
a.commitTimer = 0.0f;
|
||||
a.state = AgentState::HOLDING;
|
||||
a.currentTargetId = -1;
|
||||
a.hasLane = false;
|
||||
|
||||
// Недорубленный взмах пережил бы расстановку и уехал в следующий
|
||||
// сценарий приёмки. Параметры при этом не трогаем — они из tuning.
|
||||
a.melee.state = MeleeState::IDLE;
|
||||
a.melee.phase = 0.0f;
|
||||
a.melee.cooldown = 0.0f;
|
||||
a.melee.targetId = -1;
|
||||
a.melee.inReach = false;
|
||||
a.melee.arcBlockedByAlly = false;
|
||||
a.melee.hitMask = 0;
|
||||
a.melee.comboStep = 0; // связка тоже не должна переезжать в новый сценарий
|
||||
a.melee.comboTimer = 0.0f;
|
||||
|
||||
// Магазин тоже относится к расстановке: недозаряженный ствол переехал бы
|
||||
// в следующий сценарий приёмки вместе с бойцом.
|
||||
a.gun.ammo = a.gun.def.magazine;
|
||||
a.gun.reloadTimer = 0.0f;
|
||||
a.gun.cooldown = 0.0f;
|
||||
a.gun.recoilDeg = 0.0f;
|
||||
a.gun.shotSeq = 0;
|
||||
}
|
||||
|
||||
ClearBullets(world);
|
||||
lanes.Clear();
|
||||
metrics.Reset();
|
||||
events.Clear();
|
||||
simTime = 0.0f;
|
||||
}
|
||||
|
||||
SolverContext Game::MakeContext() const
|
||||
{
|
||||
SolverContext ctx;
|
||||
ctx.map = ↦
|
||||
ctx.agents = squad.agents.data();
|
||||
ctx.agentCount = NUM_AGENTS;
|
||||
ctx.targets = &targets;
|
||||
ctx.lanes = &lanes;
|
||||
ctx.anchor = squad.anchor;
|
||||
ctx.assignedTargetId = aim.manual ? aim.assignedTargetId : -1;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
void Game::Step(float dt)
|
||||
{
|
||||
if (in.setFormation >= 0 && in.setFormation < int(Formation::COUNT))
|
||||
squad.formation = Formation(in.setFormation);
|
||||
|
||||
// --- 0. статы снаряжения: с сущностей-оружия на бойцов ---------------------
|
||||
// Одна система в начале шага. Всё остальное дальше читает уже готовые числа
|
||||
// и не лазает в ECS: ai/ про мир сущностей не знает вообще.
|
||||
for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i]);
|
||||
|
||||
UpdateTargets(targets, dt);
|
||||
|
||||
// --- 1. якорь и состояние отряда -----------------------------------------
|
||||
// Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде.
|
||||
const Vec2 moveWorld = iso::ScreenDirToWorld(in.moveScreen);
|
||||
squad.UpdateAnchorAndState(moveWorld, in.sprint, map, dt);
|
||||
|
||||
// --- 2. движение (реестр линий — с предыдущего шага, задержка 1/60 с) -----
|
||||
squad.UpdateMovement(map, enableLaneAvoid ? &lanes : nullptr, targets, dt);
|
||||
|
||||
// --- 3. ручное прицеливание (6.8) ----------------------------------------
|
||||
UpdateAim(dt);
|
||||
|
||||
// --- 4. цели по НОВЫМ позициям: линии огня у стрелков, сектор у клинков ---
|
||||
SolverContext ctx = MakeContext();
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
Agent& a = squad.agents[i];
|
||||
if (a.IsMelee()) EvaluateMeleeTarget(a, i, ctx);
|
||||
else EvaluateAgentLane(a, i, ctx);
|
||||
}
|
||||
|
||||
// --- 5. реестр активных линий (6.6) --------------------------------------
|
||||
lanes.Clear();
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
const Agent& a = squad.agents[i];
|
||||
|
||||
if (a.IsMelee())
|
||||
{
|
||||
// Траектория клинка резервируется тем же механизмом, что и линия
|
||||
// огня: свои расступаются с дороги замаха, а не выясняют это телом.
|
||||
// Резерв задаётся явно и шире отрезка — по боковому размаху сектора,
|
||||
// иначе союзника отжимает ровно во фланг дуги.
|
||||
if (a.melee.targetId >= 0 && (a.melee.inReach || a.melee.Committed()))
|
||||
{
|
||||
const float flank = a.melee.reach * std::sin(a.melee.HalfArc());
|
||||
lanes.Add(i, a.pos, MeleeStrikeEnd(a, ctx), flank * g_tune.meleeReserveFrac);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a.hasLane && a.currentTargetId >= 0)
|
||||
{
|
||||
lanes.Add(i, a.MuzzleTowards(targets[a.currentTargetId].pos),
|
||||
targets[a.currentTargetId].pos);
|
||||
}
|
||||
else if (a.laneIntent)
|
||||
{
|
||||
// Линия перекрыта своим: резервируем её, чтобы перекрывшего отжало
|
||||
// в сторону. Без этого сектор обстрела никогда не освободится.
|
||||
lanes.Add(i, a.MuzzleTowards(a.laneEnd), a.laneEnd);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 6. решатель огневых позиций (6.5) / подход клинка --------------------
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
Agent& a = squad.agents[i];
|
||||
a.commitTimer = std::max(0.0f, a.commitTimer - dt);
|
||||
|
||||
if (a.IsMelee())
|
||||
{
|
||||
// Решателю здесь делать нечего: у клинка одна огневая позиция —
|
||||
// вплотную к цели. Нет цели в своём круге — назад в строй.
|
||||
a.postPos = MeleeGoal(a, ctx, squad.SlotWorld(i));
|
||||
lastSolveValid[i] = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
a.solveTimer -= dt;
|
||||
if (a.solveTimer > 0.0f) continue;
|
||||
a.solveTimer += g_tune.solveInterval;
|
||||
|
||||
if (!enableSolver) continue;
|
||||
if (a.state != AgentState::HOLDING) continue;
|
||||
if (a.commitTimer > 0.0f) continue;
|
||||
if (a.priorityLane) continue; // линия к приоритетной цели уже есть
|
||||
|
||||
lastSolveValid[i] = SolveFiringPosition(a, i, ctx, lastSolve[i]);
|
||||
|
||||
// Решателю не по чему работать (ни одной цели в досягаемости) — случай,
|
||||
// который спек не описывает. Возвращаемся в свой слот строя, иначе агент,
|
||||
// отставший от отряда, остаётся стоять на месте навсегда.
|
||||
if (!lastSolveValid[i]) a.postPos = squad.SlotWorld(i);
|
||||
}
|
||||
|
||||
// --- 7. доворот ствола / клинка -------------------------------------------
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
Agent& a = squad.agents[i];
|
||||
// На замахе и ударе ось зафиксирована: доворачивать её задним числом —
|
||||
// это стрельба с самонаведением, только клинком.
|
||||
if (a.IsMelee() && a.melee.Committed())
|
||||
{
|
||||
a.facing = a.melee.dir;
|
||||
continue;
|
||||
}
|
||||
if (a.currentTargetId >= 0) AimAt(a, targets[a.currentTargetId].pos, dt);
|
||||
else AimAlongMotion(a, dt);
|
||||
}
|
||||
|
||||
// --- 8. атака: огонь и клинок ---------------------------------------------
|
||||
FireWeapons(ctx, dt);
|
||||
UpdateMelee(ctx, dt);
|
||||
|
||||
// --- 9. пули ---------------------------------------------------------------
|
||||
UpdateBullets(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, metrics, &events);
|
||||
|
||||
// --- 10. метрики -----------------------------------------------------------
|
||||
metrics.Sample(squad, dt);
|
||||
|
||||
simTime += dt;
|
||||
}
|
||||
|
||||
void Game::UpdateAim(float dt)
|
||||
{
|
||||
if (in.toggleAimMode)
|
||||
{
|
||||
aim.manual = !aim.manual;
|
||||
if (aim.manual)
|
||||
{
|
||||
// Стартуем от текущего направления отряда.
|
||||
aim.dir = AngleOf(squad.forward);
|
||||
aim.assignedTargetId = -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
aim.assignedTargetId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!aim.manual)
|
||||
{
|
||||
aim.assignedTargetId = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
aim.dir = WrapPi(aim.dir + in.aimTurn * g_tune.aimTurnRateDeg * DEG2RAD_F * dt);
|
||||
|
||||
// Назначенная цель — ближайшая мишень в конусе +-aimConeDeg от вектора прицела.
|
||||
const float cone = g_tune.aimConeDeg * DEG2RAD_F;
|
||||
|
||||
// Собираем кандидатов в конусе, отсортированных по дальности от якоря.
|
||||
int inCone[NUM_TARGETS];
|
||||
int coneCount = 0;
|
||||
for (int i = 0; i < int(targets.size()); ++i)
|
||||
{
|
||||
if (!targets[i].alive) continue;
|
||||
const Vec2 d = targets[i].pos - squad.anchor;
|
||||
if (std::fabs(AngleDiff(aim.dir, AngleOf(d))) > cone) continue;
|
||||
inCone[coneCount++] = i;
|
||||
}
|
||||
for (int i = 1; i < coneCount; ++i) // вставками, список крошечный
|
||||
{
|
||||
const int v = inCone[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 &&
|
||||
DistanceSq(targets[inCone[j]].pos, squad.anchor) > DistanceSq(targets[v].pos, squad.anchor))
|
||||
{
|
||||
inCone[j + 1] = inCone[j];
|
||||
--j;
|
||||
}
|
||||
inCone[j + 1] = v;
|
||||
}
|
||||
|
||||
if (coneCount == 0)
|
||||
{
|
||||
aim.assignedTargetId = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
int current = -1;
|
||||
for (int i = 0; i < coneCount; ++i)
|
||||
if (inCone[i] == aim.assignedTargetId) current = i;
|
||||
|
||||
if (current < 0) aim.assignedTargetId = inCone[0];
|
||||
else if (in.cycleTarget) aim.assignedTargetId = inCone[(current + 1) % coneCount];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Огонь: перезарядка, увод ствола, спуск.
|
||||
//
|
||||
// Про разброс. Раньше пуля летела РОВНО по проверенному отрезку, и это было
|
||||
// главным аргументом за «FRIENDLY_HITS = 0 без исключений». Теперь у выстрела
|
||||
// есть конус, и аргумент остался тем же — просто проверяется не отрезок, а весь
|
||||
// конус (ai/firing_solver.cpp, ConeClearOfAllies). Пуля физически не может уйти
|
||||
// туда, где не проверяли: угол отклонения берётся из ТОЙ ЖЕ величины SpreadRad,
|
||||
// по которой считалась ширина проверки.
|
||||
//
|
||||
// Разброс не случаен в смысле генератора: это чистый хеш от (стрелок, номер
|
||||
// выстрела). Прогон воспроизводим, --headless по-прежнему годится как тест.
|
||||
// -----------------------------------------------------------------------------
|
||||
void Game::FireWeapons(const SolverContext& ctx, float dt)
|
||||
{
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
Agent& a = squad.agents[i];
|
||||
a.fired = false;
|
||||
if (a.IsMelee()) continue;
|
||||
|
||||
Firearm& g = a.gun;
|
||||
g.cooldown = std::max(0.0f, g.cooldown - dt);
|
||||
// Увод ствола оседает сам: очередь «раскрывается», пауза её собирает.
|
||||
g.recoilDeg = std::max(0.0f, g.recoilDeg - g.def.recoilDecay * dt);
|
||||
|
||||
// --- перезарядка ------------------------------------------------------
|
||||
if (g.Reloading())
|
||||
{
|
||||
g.reloadTimer -= dt;
|
||||
if (g.reloadTimer <= 0.0f)
|
||||
{
|
||||
g.reloadTimer = 0.0f;
|
||||
g.ammo = g.def.magazine;
|
||||
events.Push(CombatEventKind::RELOAD_DONE, i, a.pos, FromAngle(a.facing));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (g.Empty())
|
||||
{
|
||||
g.reloadTimer = g.def.reloadTime;
|
||||
events.Push(CombatEventKind::RELOAD_START, i, a.pos, FromAngle(a.facing));
|
||||
++metrics.reloads;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Бег с курсором на вытянутом поводке — это приказ идти, а не драться.
|
||||
if (squad.sprinting) continue;
|
||||
|
||||
if (!a.hasLane || a.currentTargetId < 0) continue;
|
||||
if (g.cooldown > 0.0f) continue;
|
||||
|
||||
const Vec2 tp = targets[a.currentTargetId].pos;
|
||||
|
||||
// Ствол должен быть доведён до цели — иначе выстрел выглядит враньём.
|
||||
if (std::fabs(AngleDiff(a.facing, AngleOf(tp - a.pos))) >
|
||||
g_tune.aimToleranceDeg * DEG2RAD_F)
|
||||
continue;
|
||||
|
||||
// Повторная проверка конуса по позициям этого шага: «никаких исключений».
|
||||
if (!LaneClearFrom(a.pos, i, tp, ctx)) continue;
|
||||
|
||||
const Vec2 muzzle = a.MuzzleTowards(tp);
|
||||
|
||||
// И проверка на время полёта пули — свой не должен успеть влезть.
|
||||
if (AllyInterceptsShot(muzzle, tp, i, ctx)) continue;
|
||||
|
||||
// Отклонение внутри уже проверенного конуса.
|
||||
const float spread = a.SpreadRad();
|
||||
const float axis = AngleOf(Normalized(tp - muzzle, FromAngle(a.facing)));
|
||||
const Vec2 dir = FromAngle(axis + g.ShotOffset(i, spread));
|
||||
|
||||
SpawnBullet(world, g.def, muzzle, dir, Distance(muzzle, tp), i);
|
||||
events.Push(CombatEventKind::SHOT, i, muzzle, dir);
|
||||
|
||||
g.cooldown = g.def.fireInterval;
|
||||
g.ammo = std::max(0, g.ammo - 1);
|
||||
++g.shotSeq;
|
||||
g.recoilDeg = std::min(g.def.spreadRecoilMax, g.recoilDeg + g.def.spreadRecoilDeg);
|
||||
a.fired = true;
|
||||
++metrics.shotsFired;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ближний бой: замах -> удар -> восстановление.
|
||||
//
|
||||
// Правило то же, что и у стрельбы, только в угловой форме: свой в секторе —
|
||||
// не бьём. Оно проверяется дважды, как и у ствола:
|
||||
// 1) на старте замаха — сектор reach x arcDeg обязан быть чист;
|
||||
// 2) каждый шаг активного окна — если свой всё-таки влез под лезвие,
|
||||
// взмах ОБРЫВАЕТСЯ. Урон союзнику не наносится ни при каком раскладе,
|
||||
// поэтому FRIENDLY_HITS клинок физически поднять не может.
|
||||
//
|
||||
// Урон идёт не «по факту начала удара», а по реально пройденной за шаг полосе
|
||||
// лезвия: то, что видно на экране, и то, что получило урон, — одна и та же дуга.
|
||||
// -----------------------------------------------------------------------------
|
||||
void Game::UpdateMelee(const SolverContext& ctx, float dt)
|
||||
{
|
||||
const float bladeHalf = g_tune.meleeBladeDeg * 0.5f * DEG2RAD_F;
|
||||
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
Agent& a = squad.agents[i];
|
||||
if (!a.IsMelee()) continue;
|
||||
|
||||
MeleeWeapon& m = a.melee;
|
||||
m.cooldown = std::max(0.0f, m.cooldown - dt);
|
||||
a.fired = false;
|
||||
|
||||
switch (m.state)
|
||||
{
|
||||
case MeleeState::IDLE:
|
||||
{
|
||||
// Связка живёт своим окном: не продолжил вовремя — начинаешь
|
||||
// серию сначала.
|
||||
m.comboTimer = std::max(0.0f, m.comboTimer - dt);
|
||||
if (m.comboTimer <= 0.0f) m.comboStep = 0;
|
||||
|
||||
// На бегу боец не гоняется за целями (за него это решает
|
||||
// Squad::GoalFor — он идёт в слот), но если цель сама оказалась
|
||||
// под рукой, клинок работает. Бой на ходу — это нормально.
|
||||
if (m.targetId < 0 || !m.inReach) break;
|
||||
if (m.cooldown > 0.0f) break;
|
||||
if (m.arcBlockedByAlly) break; // посчитано в EvaluateMeleeTarget
|
||||
|
||||
const Vec2 tp = targets[m.targetId].pos;
|
||||
const float axis = AngleOf(tp - a.pos);
|
||||
// Корпус должен быть довёрнут — иначе удар выглядит враньём
|
||||
// ровно так же, как выстрел мимо ствола.
|
||||
if (std::fabs(AngleDiff(a.facing, axis)) >
|
||||
g_tune.meleeAimToleranceDeg * DEG2RAD_F)
|
||||
break;
|
||||
|
||||
const MeleeComboStep& cs = m.NextComboStep();
|
||||
m.BakeSwing(cs); // ширина, урон и тайминги — на весь взмах
|
||||
|
||||
m.state = MeleeState::WINDUP;
|
||||
m.phase = m.curWindup;
|
||||
m.dir = axis;
|
||||
if (cs.flipSide) m.sweepSign = -m.sweepSign;
|
||||
m.hitMask = 0;
|
||||
// Интервал считается от НАЧАЛА замаха; внутри связки он короче.
|
||||
m.cooldown = m.interval * cs.intervalMul;
|
||||
break;
|
||||
}
|
||||
|
||||
case MeleeState::WINDUP:
|
||||
{
|
||||
m.phase -= dt;
|
||||
if (m.phase > 0.0f) break;
|
||||
m.state = MeleeState::STRIKE;
|
||||
m.phase = m.curStrike;
|
||||
events.Push(CombatEventKind::MELEE_SWING, i, a.pos, FromAngle(m.dir));
|
||||
++metrics.meleeSwings;
|
||||
if (m.comboStep >= 0 && m.comboStep < MELEE_COMBO_STEPS)
|
||||
++metrics.meleeComboSwings[m.comboStep];
|
||||
break;
|
||||
}
|
||||
|
||||
case MeleeState::STRIKE:
|
||||
{
|
||||
const float p0 = m.Progress();
|
||||
m.phase -= dt;
|
||||
const float p1 = m.Progress();
|
||||
|
||||
const float ang0 = m.BladeAngleAt(p0);
|
||||
const float ang1 = m.BladeAngleAt(p1);
|
||||
|
||||
// 1. свой под лезвием -> взмах оборван
|
||||
bool aborted = false;
|
||||
for (int k = 0; k < ctx.agentCount; ++k)
|
||||
{
|
||||
if (k == i) continue;
|
||||
const Agent& ally = ctx.agents[k];
|
||||
if (!BladeSweepHits(a.pos, ang0, ang1, bladeHalf,
|
||||
m.reach + g_tune.meleeAllyMargin, ally.pos,
|
||||
ally.bodyRadius + g_tune.meleeAllyMargin))
|
||||
continue;
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
if (aborted)
|
||||
{
|
||||
events.Push(CombatEventKind::MELEE_ABORT, i, a.pos, FromAngle(ang1));
|
||||
++metrics.meleeAborts;
|
||||
m.state = MeleeState::RECOVER;
|
||||
m.phase = m.curRecover;
|
||||
break;
|
||||
}
|
||||
|
||||
// 2. мишени, по одной за взмах
|
||||
for (int t = 0; t < int(targets.size()); ++t)
|
||||
{
|
||||
Target& tg = targets[t];
|
||||
if (!tg.alive) continue;
|
||||
if (m.hitMask & (1u << t)) continue;
|
||||
if (!BladeSweepHits(a.pos, ang0, ang1, bladeHalf, m.reach, tg.pos, tg.radius))
|
||||
continue;
|
||||
if (map.RaycastBlocked(a.pos, tg.pos)) continue; // сквозь стену не рубим
|
||||
|
||||
m.hitMask |= (1u << t);
|
||||
a.fired = true;
|
||||
|
||||
const Vec2 dir = Normalized(tg.pos - a.pos, FromAngle(m.dir));
|
||||
const Vec2 contact = tg.pos - dir * tg.radius;
|
||||
|
||||
tg.hp -= m.curDamage;
|
||||
KnockTarget(tg, dir, m.curKnock);
|
||||
events.Push(CombatEventKind::MELEE_HIT, i, contact, dir);
|
||||
++metrics.meleeHits;
|
||||
|
||||
// Инвариант сектора: попадание обязано лежать внутри дуги
|
||||
// ЭТОГО взмаха. Замеряется превышение — оно должно быть ~0.
|
||||
const float off = std::fabs(AngleDiff(m.dir, AngleOf(tg.pos - a.pos)));
|
||||
const float allowed = m.curHalfArc + bladeHalf;
|
||||
metrics.meleeMaxHitExcessDeg =
|
||||
std::max(metrics.meleeMaxHitExcessDeg,
|
||||
std::max(0.0f, off - allowed) / DEG2RAD_F);
|
||||
|
||||
if (tg.hp <= 0.0f)
|
||||
{
|
||||
tg.alive = false;
|
||||
tg.respawnTimer = g_tune.targetRespawn;
|
||||
++metrics.targetsKilled;
|
||||
events.Push(CombatEventKind::HIT_KILL, i, tg.pos, dir);
|
||||
}
|
||||
}
|
||||
|
||||
if (m.phase <= 0.0f)
|
||||
{
|
||||
m.state = MeleeState::RECOVER;
|
||||
m.phase = m.curRecover;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case MeleeState::RECOVER:
|
||||
{
|
||||
m.phase -= dt;
|
||||
if (m.phase <= 0.0f)
|
||||
{
|
||||
m.state = MeleeState::IDLE;
|
||||
m.phase = 0.0f;
|
||||
// Удар доведён до конца — связка продвигается. Окно открыто:
|
||||
// успел ударить снова, пока оно не истекло, — пойдёт
|
||||
// следующий удар серии, не успел — начнёшь сначала.
|
||||
m.comboStep = (m.comboStep + 1) % MELEE_COMBO_STEPS;
|
||||
m.comboTimer = g_tune.meleeComboWindow;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/game.h
Normal file
83
src/game.h
Normal file
@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Контейнер состояния прототипа: карта, мир ECS, отряд, мишени, пули, метрики.
|
||||
// main.cpp занимается только окном и fixed timestep, вся склейка — здесь.
|
||||
// Ввод приходит снаружи структурой InputState (нужно и для headless-прогонов).
|
||||
//
|
||||
// Мишени, пули, бойцы и оружие — сущности одного мира ecs::World. targets и
|
||||
// bullets ниже — это ССЫЛКИ на плотные массивы соответствующих пулов, а не
|
||||
// отдельные контейнеры: правила ходят по ним как по обычным векторам и про ECS
|
||||
// ничего не знают (docs/12-ecs.md).
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <vector>
|
||||
|
||||
#include "ai/firing_solver.h"
|
||||
#include "ai/lane_registry.h"
|
||||
#include "ai/melee.h"
|
||||
#include "core/input.h"
|
||||
#include "core/math.h"
|
||||
#include "debug/metrics.h"
|
||||
#include "ecs/ecs.h"
|
||||
#include "render/sprites.h"
|
||||
#include "sim/events.h"
|
||||
#include "sim/bullet.h"
|
||||
#include "sim/squad.h"
|
||||
#include "sim/target.h"
|
||||
#include "world/tilemap.h"
|
||||
|
||||
// Ручное прицеливание (спек 6.8).
|
||||
struct AimState
|
||||
{
|
||||
bool manual = false;
|
||||
float dir = 0.0f; // радианы, куда смотрит вектор прицела
|
||||
int assignedTargetId = -1;
|
||||
};
|
||||
|
||||
struct Game
|
||||
{
|
||||
Tilemap map;
|
||||
SpriteSet sprites;
|
||||
|
||||
ecs::World world;
|
||||
|
||||
Squad squad;
|
||||
LaneRegistry lanes;
|
||||
std::vector<Target>& targets; // пул ECS
|
||||
std::vector<Bullet>& bullets; // пул ECS
|
||||
Metrics metrics;
|
||||
AimState aim;
|
||||
// Единственный канал в сторону эффектов. Правила его не читают.
|
||||
CombatEventLog events;
|
||||
|
||||
// Последний прогон решателя по каждому агенту — только для F3-оверлея.
|
||||
SolveResult lastSolve[NUM_AGENTS];
|
||||
bool lastSolveValid[NUM_AGENTS] = {};
|
||||
|
||||
// Dev-тумблеры для A/B проверки ядра (по умолчанию включено).
|
||||
bool enableSolver = true;
|
||||
bool enableLaneAvoid = true;
|
||||
|
||||
InputState in{};
|
||||
float simTime = 0.0f;
|
||||
|
||||
Game();
|
||||
|
||||
void Init(); // одноразовая инициализация (спрайты + Reset)
|
||||
void Reset(); // R: перегенерировать уровень и расставить отряд
|
||||
|
||||
// Поставить отряд в заданную точку. clumped = все пятеро в одной куче —
|
||||
// это исходная позиция сценария из критерия приёмки 11.1.
|
||||
void PlaceSquad(Vec2 at, bool clumped);
|
||||
|
||||
void Step(float dt); // ровно один шаг фиксированной симуляции
|
||||
|
||||
SolverContext MakeContext() const;
|
||||
|
||||
private:
|
||||
void UpdateAim(float dt);
|
||||
// Перезарядка, увод ствола, спуск. Разброс выстрела считается здесь же и
|
||||
// ровно тот, который проверил LaneClear.
|
||||
void FireWeapons(const SolverContext& ctx, float dt);
|
||||
// Машина состояний клинка: замах -> удар -> восстановление, урон по дуге.
|
||||
void UpdateMelee(const SolverContext& ctx, float dt);
|
||||
};
|
||||
181
src/main.cpp
Normal file
181
src/main.cpp
Normal file
@ -0,0 +1,181 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Точка входа: окно, ввод, главный цикл, fixed timestep 60 Гц.
|
||||
// Ничего игрового здесь нет — только сбор ввода и вызовы Game::Step/Render.
|
||||
//
|
||||
// Dev-режимы (подробнее в docs/01-build-and-run.md):
|
||||
// --shot [кадров] [файл] снять скриншот и выйти
|
||||
// --clean стартовать без оверлея (чистый кадр)
|
||||
// --headless <сек> прогон без окна + метрики
|
||||
// --accept сценарии критериев приёмки (спек 11)
|
||||
// --probe подробности по агентам на стоянках
|
||||
// --no-solver / --no-laneavoid отключить части ядра для A/B сравнения
|
||||
// --melee N сколько слотов держат клинок (0 = все стрелки)
|
||||
// --pistol N сколько ПОСЛЕДНИХ слотов носят пистолет
|
||||
// --set имя=значение подкрутить параметр из TUNABLES
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <raylib.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "debug/harness.h"
|
||||
#include "debug/overlay.h"
|
||||
#include "game.h"
|
||||
#include "render/framebuffer.h"
|
||||
#include "render/view.h"
|
||||
#include "tuning.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
InputState SampleInput()
|
||||
{
|
||||
InputState in;
|
||||
if (IsKeyDown(KEY_A)) in.moveScreen.x -= 1.0f;
|
||||
if (IsKeyDown(KEY_D)) in.moveScreen.x += 1.0f;
|
||||
if (IsKeyDown(KEY_W)) in.moveScreen.y -= 1.0f;
|
||||
if (IsKeyDown(KEY_S)) in.moveScreen.y += 1.0f;
|
||||
in.sprint = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
|
||||
|
||||
in.toggleAimMode = IsKeyPressed(KEY_SPACE);
|
||||
in.cycleTarget = IsKeyPressed(KEY_TAB);
|
||||
if (IsKeyDown(KEY_LEFT)) in.aimTurn -= 1.0f;
|
||||
if (IsKeyDown(KEY_RIGHT)) in.aimTurn += 1.0f;
|
||||
|
||||
if (IsKeyPressed(KEY_ONE)) in.setFormation = 0;
|
||||
if (IsKeyPressed(KEY_TWO)) in.setFormation = 1;
|
||||
if (IsKeyPressed(KEY_THREE)) in.setFormation = 2;
|
||||
return in;
|
||||
}
|
||||
|
||||
// --set имя=значение — подкрутка любого параметра из TUNABLES без пересборки.
|
||||
void ApplyTuningOverride(const char* arg)
|
||||
{
|
||||
const char* eq = std::strchr(arg, '=');
|
||||
if (!eq) return;
|
||||
|
||||
const size_t nameLen = size_t(eq - arg);
|
||||
for (int k = 0; k < TUNABLE_COUNT; ++k)
|
||||
{
|
||||
if (std::strlen(TUNABLES[k].name) != nameLen) continue;
|
||||
if (std::strncmp(arg, TUNABLES[k].name, nameLen) != 0) continue;
|
||||
g_tune.*(TUNABLES[k].member) = float(std::atof(eq + 1));
|
||||
std::printf("set %s = %s\n", TUNABLES[k].name, eq + 1);
|
||||
return;
|
||||
}
|
||||
std::printf("неизвестный параметр: %s\n", arg);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
int shotFrame = -1;
|
||||
const char* shotFile = "shot.png";
|
||||
bool accept = false;
|
||||
bool clean = false; // --clean: стартовать без оверлея (для скриншотов)
|
||||
HarnessOptions harness;
|
||||
harness.seconds = 0.0f;
|
||||
|
||||
for (int i = 1; i < argc; ++i)
|
||||
{
|
||||
if (std::strcmp(argv[i], "--shot") == 0)
|
||||
{
|
||||
shotFrame = (i + 1 < argc) ? TextToInteger(argv[i + 1]) : 90;
|
||||
if (i + 2 < argc) shotFile = argv[i + 2];
|
||||
}
|
||||
else if (std::strcmp(argv[i], "--headless") == 0 && i + 1 < argc)
|
||||
harness.seconds = float(std::atof(argv[++i]));
|
||||
else if (std::strcmp(argv[i], "--set") == 0 && i + 1 < argc)
|
||||
ApplyTuningOverride(argv[++i]);
|
||||
else if (std::strcmp(argv[i], "--melee") == 0 && i + 1 < argc)
|
||||
harness.meleeCount = TextToInteger(argv[++i]);
|
||||
else if (std::strcmp(argv[i], "--pistol") == 0 && i + 1 < argc)
|
||||
harness.pistolCount = TextToInteger(argv[++i]);
|
||||
else if (std::strcmp(argv[i], "--clean") == 0) clean = true;
|
||||
else if (std::strcmp(argv[i], "--accept") == 0) accept = true;
|
||||
else if (std::strcmp(argv[i], "--probe") == 0) harness.probe = true;
|
||||
else if (std::strcmp(argv[i], "--no-solver") == 0) harness.solver = false;
|
||||
else if (std::strcmp(argv[i], "--no-laneavoid") == 0) harness.laneAvoid = false;
|
||||
}
|
||||
|
||||
if (accept) return RunAccept();
|
||||
if (harness.seconds > 0.0f) return RunHeadless(harness);
|
||||
|
||||
SetConfigFlags(FLAG_VSYNC_HINT);
|
||||
InitWindow(WINDOW_W, WINDOW_H, "Squad prototype");
|
||||
SetExitKey(KEY_ESCAPE);
|
||||
|
||||
Framebuffer fb;
|
||||
fb.Init(INTERNAL_W, INTERNAL_H);
|
||||
|
||||
ScreenBlitter screen;
|
||||
screen.Init(INTERNAL_W, INTERNAL_H);
|
||||
|
||||
Game game;
|
||||
game.enableSolver = harness.solver;
|
||||
game.enableLaneAvoid = harness.laneAvoid;
|
||||
game.squad.meleeCount = harness.meleeCount; // до Init: состав раздаётся там
|
||||
game.squad.pistolCount = harness.pistolCount;
|
||||
game.Init();
|
||||
|
||||
// View большой (поля света на всю карту + виньетка на кадр) — на куче.
|
||||
static View view;
|
||||
view.Init();
|
||||
view.Reset(game);
|
||||
|
||||
Overlay overlay;
|
||||
if (clean) { overlay.show = false; overlay.showLanes = false; }
|
||||
double accumulator = 0.0;
|
||||
|
||||
while (!WindowShouldClose())
|
||||
{
|
||||
overlay.HandleKeys();
|
||||
if (IsKeyPressed(KEY_R)) { game.Reset(); view.Reset(game); }
|
||||
if (IsKeyPressed(KEY_F5)) view.horrorEnabled = !view.horrorEnabled;
|
||||
if (IsKeyPressed(KEY_F6)) view.showSight = !view.showSight;
|
||||
|
||||
game.in = SampleInput();
|
||||
|
||||
const float frameDt = GetFrameTime();
|
||||
accumulator += double(frameDt);
|
||||
|
||||
// --- fixed timestep 60 Гц, рендер с интерполяцией ---------------------
|
||||
int steps = 0;
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
while (accumulator >= FIXED_DT && steps < MAX_STEPS_PER_FRAME)
|
||||
{
|
||||
game.Step(FIXED_DT);
|
||||
accumulator -= FIXED_DT;
|
||||
++steps;
|
||||
}
|
||||
game.metrics.simMs = float(
|
||||
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count());
|
||||
if (accumulator > FIXED_DT * MAX_STEPS_PER_FRAME) accumulator = 0.0;
|
||||
|
||||
const float alpha = float(accumulator / FIXED_DT);
|
||||
|
||||
const auto tr0 = std::chrono::steady_clock::now();
|
||||
RenderGame(game, view, fb, alpha, frameDt);
|
||||
game.metrics.renderMs = float(
|
||||
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - tr0).count());
|
||||
|
||||
BeginDrawing();
|
||||
ClearBackground(BLACK);
|
||||
screen.Present(fb, WINDOW_W, WINDOW_H);
|
||||
overlay.Draw(game, view);
|
||||
EndDrawing();
|
||||
|
||||
if (shotFrame >= 0 && --shotFrame <= 0)
|
||||
{
|
||||
TakeScreenshot(shotFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
screen.Shutdown();
|
||||
CloseWindow();
|
||||
return 0;
|
||||
}
|
||||
83
src/render/atmos.cpp
Normal file
83
src/render/atmos.cpp
Normal file
@ -0,0 +1,83 @@
|
||||
#include "render/atmos.h"
|
||||
|
||||
#include "render/iso.h"
|
||||
#include "render/noise.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Мировой квадрат вокруг камеры, гарантированно покрывающий кадр 480x270.
|
||||
// В изометрии |wx-wy| <= 15 по ширине и |wx+wy| <= 17 по высоте, так что 19
|
||||
// тайлов в каждую сторону — с запасом на подъём пылинки по экрану.
|
||||
constexpr int ASH_REACH = 19;
|
||||
|
||||
// Больше трёх пылинок на тайл — это уже не воздух, а снегопад.
|
||||
constexpr int ASH_PER_CELL = 3;
|
||||
|
||||
constexpr uint32_t COL_ASH = FXC(198, 186, 166); // тёплый серый: пепел, не снег
|
||||
|
||||
} // namespace
|
||||
|
||||
void Atmosphere::DrawAsh(Framebuffer& fb, Vec2 camPos, int ox, int oy, const Lighting& light) const
|
||||
{
|
||||
const float density = g_tune.ashDensity;
|
||||
const float bright = g_tune.ashBrightness;
|
||||
if (density <= 0.0f || bright <= 0.0f) return;
|
||||
|
||||
const int cx = int(std::floor(camPos.x));
|
||||
const int cy = int(std::floor(camPos.y));
|
||||
const float drift = g_tune.ashSpeed;
|
||||
|
||||
for (int gy = cy - ASH_REACH; gy <= cy + ASH_REACH; ++gy)
|
||||
{
|
||||
for (int gx = cx - ASH_REACH; gx <= cx + ASH_REACH; ++gx)
|
||||
{
|
||||
for (int k = 0; k < ASH_PER_CELL; ++k)
|
||||
{
|
||||
// Дробная плотность: последней пылинке в ячейке «положено»
|
||||
// столько, сколько осталось от density.
|
||||
const float quota = density - float(k);
|
||||
if (quota <= 0.0f) break;
|
||||
|
||||
const uint32_t h = Hash3(uint32_t(gx + 4096), uint32_t(gy + 4096),
|
||||
uint32_t(k) * 977u + 13u);
|
||||
if (quota < 1.0f && Hash01(h) > quota) continue;
|
||||
|
||||
const Vec2 base{float(gx) + Hash01(Hash2(h, 1u)),
|
||||
float(gy) + Hash01(Hash2(h, 2u))};
|
||||
|
||||
// Дрейф — замкнутая петля, а не бесконечный снос: иначе пылинку
|
||||
// пришлось бы «заворачивать», и она телепортировалась бы на глазах.
|
||||
const float ph = Hash01(Hash2(h, 3u)) * TWO_PI_F;
|
||||
const float rate = 0.20f + 0.45f * Hash01(Hash2(h, 4u));
|
||||
const float amp = 0.30f + 0.30f * Hash01(Hash2(h, 5u));
|
||||
const float t = time * drift * rate;
|
||||
const Vec2 p{base.x + std::cos(t * TWO_PI_F + ph) * amp,
|
||||
base.y + std::sin(t * TWO_PI_F * 0.73f + ph * 1.7f) * amp};
|
||||
|
||||
const float lit = light.LightAt(p);
|
||||
if (lit < 0.05f) continue;
|
||||
|
||||
// Высота: часть взвеси стелется по полу, часть висит на уровне
|
||||
// головы. Медленное покачивание вверх-вниз добавляет объёма.
|
||||
const float liftFrac = 0.15f + 0.95f * Hash01(Hash2(h, 6u));
|
||||
const float bob = std::sin(t * TWO_PI_F * 1.31f + ph) * 2.5f;
|
||||
const int lift = int(g_tune.ashLiftPx * liftFrac + bob);
|
||||
|
||||
const Vec2 s = iso::WorldToScreen(p);
|
||||
const int sx = int(std::lround(s.x)) + ox;
|
||||
const int sy = int(std::lround(s.y)) + oy - lift;
|
||||
if (unsigned(sx) >= unsigned(fb.w) || unsigned(sy) >= unsigned(fb.h)) continue;
|
||||
|
||||
// Мерцание: пылинка поворачивается к свету то одним боком, то другим.
|
||||
const float twinkle = 0.45f + 0.55f * (0.5f + 0.5f * std::sin(t * 9.0f + ph * 3.0f));
|
||||
const float amount = lit * bright * twinkle * (0.35f + 0.65f * Hash01(Hash2(h, 8u)));
|
||||
const uint32_t col = ScaleColor8(COL_ASH, uint32_t(Clampf(amount, 0.0f, 1.0f) * 256.0f));
|
||||
|
||||
// Крупные хлопья редки и получают ореол — глазу есть за что зацепиться.
|
||||
if ((h & 15u) == 0u) fb.AddGlowDisc(sx, sy, 1, col);
|
||||
else fb.AddPx(sx, sy, col);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/render/atmos.h
Normal file
32
src/render/atmos.h
Normal file
@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Воздух: висящая пыль (пепел) и низовая дымка.
|
||||
//
|
||||
// Разделение сознательное, потому что это две разные вещи:
|
||||
// * ПЕПЕЛ — частицы в МИРЕ. Он парит между камерой и полом, двигается вместе
|
||||
// со сценой при движении камеры и подчиняется свету: в темноте пылинок не
|
||||
// видно, в луче они вспыхивают. Рисуется здесь;
|
||||
// * ДЫМКА — свойство КАДРА, а не мира: мягкое подсвечивание объёма клубами.
|
||||
// Она живёт в слитом пост-проходе (render/post.cpp), потому что там уже
|
||||
// есть проход по пикселям и второй заводить незачем.
|
||||
//
|
||||
// Ни у того, ни у другого нет состояния, которое надо синхронизировать:
|
||||
// положение пылинки — чистая функция от её ячейки мира и времени. Поэтому
|
||||
// «частиц» нет как объектов, аллокаций ноль, и число пылинок можно менять
|
||||
// ручкой в оверлее прямо на ходу.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "core/math.h"
|
||||
#include "render/framebuffer.h"
|
||||
#include "render/lighting.h"
|
||||
|
||||
struct Atmosphere
|
||||
{
|
||||
float time = 0.0f;
|
||||
|
||||
void Reset() { time = 0.0f; }
|
||||
void Update(float frameDt) { time += frameDt; }
|
||||
|
||||
// Взвесь поверх геометрии и до пост-прохода: пыль светится аддитивно,
|
||||
// но общую цветокоррекцию кадра проходит вместе со всем остальным.
|
||||
void DrawAsh(Framebuffer& fb, Vec2 camPos, int ox, int oy, const Lighting& light) const;
|
||||
};
|
||||
233
src/render/blood.cpp
Normal file
233
src/render/blood.cpp
Normal file
@ -0,0 +1,233 @@
|
||||
#include "render/blood.h"
|
||||
|
||||
#include "render/iso.h"
|
||||
#include "render/noise.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Максимум капель в одном пятне. Больше — это уже не брызги, а забор.
|
||||
constexpr int MAX_DROPS = 16;
|
||||
|
||||
// Одна капля пятна: круг с рваным краем, при желании вытянутый вдоль удара.
|
||||
struct Blob
|
||||
{
|
||||
Vec2 c{};
|
||||
float r = 0.0f;
|
||||
float strength = 1.0f;
|
||||
float elong = 1.0f; // вытяжение вдоль dir; 1 = ровный круг
|
||||
uint32_t seed = 0;
|
||||
};
|
||||
|
||||
// Множитель цвета по каналам с насыщением. Кровь именно УМНОЖАЕТ пол, а не
|
||||
// закрашивает его: фактура плитки должна просвечивать, иначе получается наклейка.
|
||||
inline uint32_t MulColor(uint32_t c, float kr, float kg, float kb)
|
||||
{
|
||||
const float r = Clampf(float(c & 0xFFu) * kr, 0.0f, 255.0f);
|
||||
const float g = Clampf(float((c >> 8) & 0xFFu) * kg, 0.0f, 255.0f);
|
||||
const float b = Clampf(float((c >> 16) & 0xFFu) * kb, 0.0f, 255.0f);
|
||||
return RGBA(uint8_t(r), uint8_t(g), uint8_t(b), AlphaOf(c));
|
||||
}
|
||||
|
||||
inline uint32_t MixColor(uint32_t a, uint32_t b, float t)
|
||||
{
|
||||
const float k = Clampf(t, 0.0f, 1.0f);
|
||||
return RGBA(uint8_t(Lerpf(float(a & 0xFFu), float(b & 0xFFu), k)),
|
||||
uint8_t(Lerpf(float((a >> 8) & 0xFFu), float((b >> 8) & 0xFFu), k)),
|
||||
uint8_t(Lerpf(float((a >> 16) & 0xFFu), float((b >> 16) & 0xFFu), k)),
|
||||
AlphaOf(a));
|
||||
}
|
||||
|
||||
// Рваный край: радиус зависит от угла, между лепестками — гладкая интерполяция.
|
||||
// Идеальный круг читается как штамп, а не как лужа.
|
||||
float EdgeWobble(uint32_t seed, float ang)
|
||||
{
|
||||
constexpr int LOBES = 12;
|
||||
const float t = (ang + PI_F) / TWO_PI_F * float(LOBES);
|
||||
const int i0 = int(t) % LOBES;
|
||||
const int i1 = (i0 + 1) % LOBES;
|
||||
float f = t - std::floor(t);
|
||||
f = f * f * (3.0f - 2.0f * f);
|
||||
const float w0 = Hash01(Hash3(seed, uint32_t(i0), 0x5Bu));
|
||||
const float w1 = Hash01(Hash3(seed, uint32_t(i1), 0x5Bu));
|
||||
return 0.72f + 0.44f * Lerpf(w0, w1, f);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void BloodField::Reset()
|
||||
{
|
||||
slotOf.assign(size_t(MAP_W) * size_t(MAP_H), -1);
|
||||
stained.clear();
|
||||
eventCursor = 0;
|
||||
splats = 0;
|
||||
}
|
||||
|
||||
void BloodField::Ingest(const CombatEventLog& log, const SpriteSet& sprites, const Tilemap& map)
|
||||
{
|
||||
uint32_t from = eventCursor;
|
||||
if (log.head > from + COMBAT_EVENT_CAP) from = log.head - COMBAT_EVENT_CAP;
|
||||
|
||||
for (uint32_t i = from; i < log.head; ++i)
|
||||
{
|
||||
const CombatEvent& e = log.buf[i % COMBAT_EVENT_CAP];
|
||||
|
||||
float radius = 0.0f;
|
||||
switch (e.kind)
|
||||
{
|
||||
case CombatEventKind::HIT_TARGET: radius = g_tune.bloodRadiusHit; break;
|
||||
case CombatEventKind::HIT_ALLY: radius = g_tune.bloodRadiusHit; break;
|
||||
case CombatEventKind::MELEE_HIT: radius = g_tune.bloodRadiusMelee; break;
|
||||
case CombatEventKind::HIT_KILL: radius = g_tune.bloodRadiusKill; break;
|
||||
default: continue;
|
||||
}
|
||||
// Зерно от номера события: два попадания в одну точку дадут разные пятна.
|
||||
Splat(sprites, map, e.pos, e.dir, radius, Hash2(i, uint32_t(e.owner + 1)));
|
||||
}
|
||||
eventCursor = log.head;
|
||||
}
|
||||
|
||||
void BloodField::Splat(const SpriteSet& sprites, const Tilemap& map, Vec2 world, Vec2 dir,
|
||||
float radius, uint32_t seed)
|
||||
{
|
||||
if (g_tune.bloodAmount <= 0.0f) return;
|
||||
radius *= g_tune.bloodAmount;
|
||||
if (radius < 0.02f) return;
|
||||
if (slotOf.empty()) Reset();
|
||||
|
||||
const Vec2 d = Normalized(dir, Vec2{1.0f, 0.0f});
|
||||
const Vec2 side = Perp(d);
|
||||
|
||||
// --- набор капель ---------------------------------------------------------
|
||||
Blob blobs[1 + MAX_DROPS];
|
||||
int n = 0;
|
||||
|
||||
Blob main_;
|
||||
main_.c = world;
|
||||
main_.r = radius;
|
||||
main_.strength = 1.0f;
|
||||
main_.elong = 1.35f; // лужа вытянута по удару, а не идеально кругла
|
||||
main_.seed = seed;
|
||||
blobs[n++] = main_;
|
||||
|
||||
const int drops = std::min(MAX_DROPS, std::max(0, int(g_tune.bloodDrops)));
|
||||
for (int k = 0; k < drops; ++k)
|
||||
{
|
||||
const uint32_t h = Hash3(seed, uint32_t(k), 0x11u);
|
||||
const float along = radius * (1.05f + 2.4f * (float(k) + 1.0f) / float(drops)) *
|
||||
(0.55f + 0.9f * Hash01(h));
|
||||
const float lateral = radius * 1.15f * HashSigned(Hash2(h, 7u)) *
|
||||
(0.3f + 0.7f * float(k) / float(drops));
|
||||
const float rr = radius * (0.34f - 0.20f * float(k) / float(drops)) *
|
||||
(0.45f + 0.9f * Hash01(Hash2(h, 13u)));
|
||||
if (rr < 0.02f) continue;
|
||||
|
||||
Blob b;
|
||||
b.c = world + d * along + side * lateral;
|
||||
b.r = rr;
|
||||
b.strength = 0.86f;
|
||||
b.elong = 1.0f;
|
||||
b.seed = h;
|
||||
blobs[n++] = b;
|
||||
}
|
||||
|
||||
// --- границы в мире -> диапазон тайлов ------------------------------------
|
||||
float minX = world.x, maxX = world.x, minY = world.y, maxY = world.y;
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
const float r = blobs[i].r * 1.5f;
|
||||
minX = std::min(minX, blobs[i].c.x - r);
|
||||
maxX = std::max(maxX, blobs[i].c.x + r);
|
||||
minY = std::min(minY, blobs[i].c.y - r);
|
||||
maxY = std::max(maxY, blobs[i].c.y + r);
|
||||
}
|
||||
|
||||
const int tx0 = int(std::floor(minX));
|
||||
const int tx1 = int(std::floor(maxX));
|
||||
const int ty0 = int(std::floor(minY));
|
||||
const int ty1 = int(std::floor(maxY));
|
||||
|
||||
const uint32_t coreCol = RGBA(52, 8, 10);
|
||||
const uint32_t rimCol = RGBA(104, 22, 16);
|
||||
const float opacity = Clampf(g_tune.bloodOpacity, 0.0f, 1.0f);
|
||||
|
||||
bool painted = false;
|
||||
|
||||
for (int ty = ty0; ty <= ty1; ++ty)
|
||||
{
|
||||
for (int tx = tx0; tx <= tx1; ++tx)
|
||||
{
|
||||
if (!Tilemap::InBounds(tx, ty)) continue;
|
||||
if (map.IsWall(tx, ty)) continue; // под стеной пола не видно
|
||||
|
||||
const size_t key = size_t(ty) * MAP_W + tx;
|
||||
int32_t slot = slotOf[key];
|
||||
if (slot < 0)
|
||||
{
|
||||
// Бюджет выбран: на чистый тайл больше не тратимся, но уже
|
||||
// запачканные продолжают собирать кровь.
|
||||
if (int(stained.size()) >= BLOOD_TILE_BUDGET) continue;
|
||||
slot = int32_t(stained.size());
|
||||
stained.push_back(sprites.FloorTile(tx, ty));
|
||||
slotOf[key] = slot;
|
||||
}
|
||||
|
||||
Sprite& s = stained[size_t(slot)];
|
||||
const Vec2 center{float(tx) + 0.5f, float(ty) + 0.5f};
|
||||
|
||||
for (int py = 0; py < s.h; ++py)
|
||||
{
|
||||
for (int px = 0; px < s.w; ++px)
|
||||
{
|
||||
const uint32_t base = s.Get(px, py);
|
||||
if (AlphaOf(base) == 0) continue; // вне ромба
|
||||
|
||||
// Пиксель ромба -> точка мира. Проекция линейна, поэтому
|
||||
// обратный переход точен и швов между тайлами не будет.
|
||||
const Vec2 p = center + iso::ScreenToWorld(
|
||||
Vec2{float(px - s.ax), float(py - s.ay)});
|
||||
|
||||
float cover = 0.0f;
|
||||
for (int i = 0; i < n && cover < 1.0f; ++i)
|
||||
{
|
||||
const Blob& b = blobs[i];
|
||||
Vec2 dv = p - b.c;
|
||||
if (b.elong != 1.0f)
|
||||
dv = Vec2{Dot(dv, d) / b.elong, Dot(dv, side)};
|
||||
|
||||
const float dist = Length(dv);
|
||||
const float r = b.r * EdgeWobble(b.seed, AngleOf(dv));
|
||||
if (dist >= r) continue;
|
||||
|
||||
const float inner = r * 0.68f;
|
||||
const float a = (dist <= inner)
|
||||
? 1.0f
|
||||
: 1.0f - (dist - inner) / std::max(1e-4f, r - inner);
|
||||
cover = std::max(cover, b.strength * a);
|
||||
}
|
||||
if (cover <= 0.02f) continue;
|
||||
|
||||
// Кромка светлее и рыжее середины — так лужа читается объёмной.
|
||||
const uint32_t tint = MixColor(rimCol, coreCol, Clampf(cover * 1.3f, 0.0f, 1.0f));
|
||||
// Крапчатость: свернувшаяся кровь неоднородна.
|
||||
const float mott = 0.84f + 0.26f * Hash01(Hash3(uint32_t(tx * 64 + px),
|
||||
uint32_t(ty * 64 + py), seed));
|
||||
// Плотность держится почти по всему пятну и падает только
|
||||
// у самого края: лужа с ровным градиентом выглядит подтёком.
|
||||
const float a = Clampf(cover * 1.5f, 0.0f, 1.0f) * opacity * mott;
|
||||
|
||||
// Делители подобраны так, чтобы серый пол ушёл в тёмно-красное,
|
||||
// а кромка — в заметно более светлое и рыжее.
|
||||
const uint32_t stainedPx =
|
||||
MulColor(base, Lerpf(1.0f, float(tint & 0xFFu) / 55.0f, a),
|
||||
Lerpf(1.0f, float((tint >> 8) & 0xFFu) / 110.0f, a),
|
||||
Lerpf(1.0f, float((tint >> 16) & 0xFFu) / 150.0f, a));
|
||||
s.Set(px, py, stainedPx);
|
||||
painted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (painted) ++splats;
|
||||
}
|
||||
56
src/render/blood.h
Normal file
56
src/render/blood.h
Normal file
@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Кровь: вечные декали, ЗАПЕЧЁННЫЕ в поверхность.
|
||||
//
|
||||
// Почему не список пятен, которые дорисовываются каждый кадр: их число растёт
|
||||
// без предела, и через минуту боя кадр начинает стоить столько же, сколько вся
|
||||
// остальная сцена. Обычное лекарство — выбрасывать старые декали, но задача
|
||||
// прямо противоположная: пятна должны остаться навсегда.
|
||||
//
|
||||
// Решение — запекание. Пол рисуется поштучно спрайтами-ромбами. При попадании
|
||||
// мы один раз делаем КОПИЮ ромба того тайла, куда пришлось попадание, и рисуем
|
||||
// кровь прямо в её пиксели. Дальше этот тайл рисуется ровно так же, как раньше,
|
||||
// просто другим спрайтом. Стоимость кадра не меняется НИ НА ЧТО, сколько бы
|
||||
// крови ни накопилось: цена платится один раз, в момент попадания.
|
||||
//
|
||||
// Свет и туман войны продолжают работать: запечённая кровь проходит через ту же
|
||||
// световую рампу, что и пол, потому что она и есть пол.
|
||||
//
|
||||
// Потолок памяти — BLOOD_TILE_BUDGET тайлов (по 2 КБ). Когда он выбран, новые
|
||||
// пятна ложатся только на уже запачканные тайлы: это видно как «кровь копится
|
||||
// там, где дрались», а не как исчезающие декали.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/math.h"
|
||||
#include "render/framebuffer.h"
|
||||
#include "render/sprites.h"
|
||||
#include "sim/events.h"
|
||||
#include "world/tilemap.h"
|
||||
|
||||
struct BloodField
|
||||
{
|
||||
std::vector<int32_t> slotOf; // тайл -> индекс в stained, -1 = чистый
|
||||
std::vector<Sprite> stained; // запечённые копии ромбов пола
|
||||
uint32_t eventCursor = 0;
|
||||
int splats = 0; // сколько пятен легло за прогон (для оверлея)
|
||||
|
||||
void Reset();
|
||||
|
||||
// Чем рисовать пол этого тайла: запечённой копией или ничем (значит базовым).
|
||||
const Sprite* At(int tx, int ty) const
|
||||
{
|
||||
if (!Tilemap::InBounds(tx, ty)) return nullptr;
|
||||
if (slotOf.empty()) return nullptr;
|
||||
const int32_t s = slotOf[size_t(ty) * MAP_W + tx];
|
||||
return (s >= 0) ? &stained[size_t(s)] : nullptr;
|
||||
}
|
||||
|
||||
// Забрать из журнала боя всё, что кровоточит.
|
||||
void Ingest(const CombatEventLog& log, const SpriteSet& sprites, const Tilemap& map);
|
||||
|
||||
// Одно пятно: основная лужа + брызги по направлению удара.
|
||||
void Splat(const SpriteSet& sprites, const Tilemap& map, Vec2 world, Vec2 dir, float radius,
|
||||
uint32_t seed);
|
||||
};
|
||||
332
src/render/framebuffer.cpp
Normal file
332
src/render/framebuffer.cpp
Normal file
@ -0,0 +1,332 @@
|
||||
#include "render/framebuffer.h"
|
||||
|
||||
#include <raylib.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Световая рампа
|
||||
// -----------------------------------------------------------------------------
|
||||
uint8_t g_shadeLut[RAMP_LEVELS][3][256];
|
||||
|
||||
const uint8_t BAYER4[4][4] = {
|
||||
{ 0, 8, 2, 10},
|
||||
{12, 4, 14, 6},
|
||||
{ 3, 11, 1, 9},
|
||||
{15, 7, 13, 5},
|
||||
};
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Цвет полумрака и цвет пересвета. Между ними рампа плавно интерполирует.
|
||||
constexpr float COLD[3] = {0.60f, 0.74f, 1.00f}; // холодный синий в тенях
|
||||
constexpr float WARM[3] = {1.00f, 0.92f, 0.76f}; // тёплый пересвет во вспышке
|
||||
|
||||
inline float Smooth01(float t) { return t * t * (3.0f - 2.0f * t); }
|
||||
|
||||
struct ShadeRampInit
|
||||
{
|
||||
ShadeRampInit() { BuildShadeRamp(1.35f, 1.10f); }
|
||||
};
|
||||
ShadeRampInit g_rampInit;
|
||||
|
||||
} // namespace
|
||||
|
||||
void BuildShadeRamp(float gamma, float maxGain)
|
||||
{
|
||||
for (int lvl = 0; lvl < RAMP_LEVELS; ++lvl)
|
||||
{
|
||||
// Ступень 0 — чистый чёрный: она обязана совпадать с цветом Clear,
|
||||
// иначе невидимые тайлы нельзя пропускать при отрисовке.
|
||||
if (lvl == 0)
|
||||
{
|
||||
for (int ch = 0; ch < 3; ++ch)
|
||||
for (int v = 0; v < 256; ++v) g_shadeLut[lvl][ch][v] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const float t = float(lvl) / float(RAMP_LEVELS - 1);
|
||||
const float gain = std::pow(t, gamma) * maxGain;
|
||||
const float mix = Smooth01(t);
|
||||
|
||||
for (int ch = 0; ch < 3; ++ch)
|
||||
{
|
||||
const float tint = COLD[ch] + (WARM[ch] - COLD[ch]) * mix;
|
||||
const float k = gain * tint;
|
||||
for (int v = 0; v < 256; ++v)
|
||||
{
|
||||
const float out = float(v) * k;
|
||||
g_shadeLut[lvl][ch][v] = uint8_t(out < 0.0f ? 0.0f : (out > 255.0f ? 255.0f : out));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
void Framebuffer::Init(int width, int height)
|
||||
{
|
||||
w = width;
|
||||
h = height;
|
||||
px.assign(size_t(w) * size_t(h), 0u);
|
||||
}
|
||||
|
||||
void Framebuffer::Clear(uint32_t color)
|
||||
{
|
||||
std::fill(px.begin(), px.end(), color);
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
// Общая обрезка спрайта по буферу. false — рисовать нечего.
|
||||
inline bool ClipSprite(const Sprite& s, int x, int y, int fbW, int fbH, int& dstX0, int& dstY0,
|
||||
int& sx0, int& sy0, int& sx1, int& sy1)
|
||||
{
|
||||
dstX0 = x - s.ax;
|
||||
dstY0 = y - s.ay;
|
||||
sx0 = std::max(0, -dstX0);
|
||||
sy0 = std::max(0, -dstY0);
|
||||
sx1 = std::min(s.w, fbW - dstX0);
|
||||
sy1 = std::min(s.h, fbH - dstY0);
|
||||
// Пустой диапазон -> выходим ДО вычисления адресов: иначе индекс уезжает
|
||||
// за границы vector (Debug ловит это как "vector subscript out of range").
|
||||
return sx0 < sx1 && sy0 < sy1;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void Framebuffer::Blit(const Sprite& s, int x, int y)
|
||||
{
|
||||
int dstX0, dstY0, sx0, sy0, sx1, sy1;
|
||||
if (!ClipSprite(s, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
|
||||
|
||||
for (int sy = sy0; sy < sy1; ++sy)
|
||||
{
|
||||
const uint32_t* src = s.px.data() + size_t(sy) * s.w + sx0;
|
||||
uint32_t* dst = px.data() + size_t(dstY0 + sy) * w + (dstX0 + sx0);
|
||||
for (int sx = sx0; sx < sx1; ++sx, ++src, ++dst)
|
||||
{
|
||||
const uint32_t c = *src;
|
||||
if (AlphaOf(c) != 0) *dst = c; // альфа-тест, без блендинга
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Framebuffer::BlitTinted(const Sprite& s, int x, int y, uint32_t tint)
|
||||
{
|
||||
int dstX0, dstY0, sx0, sy0, sx1, sy1;
|
||||
if (!ClipSprite(s, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
|
||||
|
||||
for (int sy = sy0; sy < sy1; ++sy)
|
||||
for (int sx = sx0; sx < sx1; ++sx)
|
||||
if (AlphaOf(s.px[size_t(sy) * s.w + sx]) != 0)
|
||||
px[size_t(dstY0 + sy) * w + (dstX0 + sx)] = tint;
|
||||
}
|
||||
|
||||
void Framebuffer::BlitShaded(const Sprite& s, int x, int y, const ShadeQuad& q)
|
||||
{
|
||||
int dstX0, dstY0, sx0, sy0, sx1, sy1;
|
||||
if (!ClipSprite(s, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
|
||||
|
||||
constexpr float MAX_LVL = float(RAMP_LEVELS - 1);
|
||||
|
||||
for (int sy = sy0; sy < sy1; ++sy)
|
||||
{
|
||||
const float py = float(sy < q.clampRow ? sy : q.clampRow);
|
||||
const float rowBase = q.a + q.c * py + q.f * py * py;
|
||||
|
||||
// Прямые разности по строке: во внутреннем цикле два сложения.
|
||||
const float fx0 = float(sx0);
|
||||
float lit = rowBase + q.b * fx0 + q.e * fx0 * fx0;
|
||||
float dl = q.b + q.e * (2.0f * fx0 + 1.0f);
|
||||
const float ddl = 2.0f * q.e;
|
||||
|
||||
const uint8_t* bayerRow = BAYER4[(dstY0 + sy) & 3];
|
||||
const uint32_t* src = s.px.data() + size_t(sy) * s.w + sx0;
|
||||
uint32_t* dst = px.data() + size_t(dstY0 + sy) * w + (dstX0 + sx0);
|
||||
|
||||
for (int sx = sx0; sx < sx1; ++sx, ++src, ++dst)
|
||||
{
|
||||
const uint32_t c = *src;
|
||||
if (AlphaOf(c) != 0)
|
||||
{
|
||||
const float dither = (float(bayerRow[(dstX0 + sx) & 3]) + 0.5f) * (1.0f / 16.0f);
|
||||
int lvl = int(lit * MAX_LVL + dither);
|
||||
if (lvl < 0) lvl = 0;
|
||||
else if (lvl >= RAMP_LEVELS) lvl = RAMP_LEVELS - 1;
|
||||
if (lvl != 0) *dst = ShadePixel(c, lvl);
|
||||
}
|
||||
lit += dl;
|
||||
dl += ddl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Framebuffer::BlitMul(const Sprite& mask, int x, int y, uint32_t k256)
|
||||
{
|
||||
int dstX0, dstY0, sx0, sy0, sx1, sy1;
|
||||
if (!ClipSprite(mask, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
|
||||
|
||||
for (int sy = sy0; sy < sy1; ++sy)
|
||||
for (int sx = sx0; sx < sx1; ++sx)
|
||||
if (AlphaOf(mask.px[size_t(sy) * mask.w + sx]) != 0)
|
||||
{
|
||||
uint32_t& d = px[size_t(dstY0 + sy) * w + (dstX0 + sx)];
|
||||
d = ScaleColor8(d, k256);
|
||||
}
|
||||
}
|
||||
|
||||
void Framebuffer::FillRect(int x, int y, int rw, int rh, uint32_t c)
|
||||
{
|
||||
const int x0 = std::max(0, x);
|
||||
const int y0 = std::max(0, y);
|
||||
const int x1 = std::min(w, x + rw);
|
||||
const int y1 = std::min(h, y + rh);
|
||||
for (int yy = y0; yy < y1; ++yy)
|
||||
for (int xx = x0; xx < x1; ++xx)
|
||||
px[size_t(yy) * w + xx] = c;
|
||||
}
|
||||
|
||||
void Framebuffer::Line(int x0, int y0, int x1, int y1, uint32_t c)
|
||||
{
|
||||
// Брезенхем. Put сам отсекает по границам.
|
||||
int dx = std::abs(x1 - x0);
|
||||
int dy = -std::abs(y1 - y0);
|
||||
const int sx = (x0 < x1) ? 1 : -1;
|
||||
const int sy = (y0 < y1) ? 1 : -1;
|
||||
int err = dx + dy;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
Put(x0, y0, c);
|
||||
if (x0 == x1 && y0 == y1) break;
|
||||
const int e2 = 2 * err;
|
||||
if (e2 >= dy) { err += dy; x0 += sx; }
|
||||
if (e2 <= dx) { err += dx; y0 += sy; }
|
||||
}
|
||||
}
|
||||
|
||||
void Framebuffer::CircleOutline(int cx, int cy, int r, uint32_t c)
|
||||
{
|
||||
int x = r;
|
||||
int y = 0;
|
||||
int err = 1 - r;
|
||||
while (x >= y)
|
||||
{
|
||||
Put(cx + x, cy + y, c); Put(cx + y, cy + x, c);
|
||||
Put(cx - y, cy + x, c); Put(cx - x, cy + y, c);
|
||||
Put(cx - x, cy - y, c); Put(cx - y, cy - x, c);
|
||||
Put(cx + y, cy - x, c); Put(cx + x, cy - y, c);
|
||||
++y;
|
||||
if (err < 0) err += 2 * y + 1;
|
||||
else { --x; err += 2 * (y - x) + 1; }
|
||||
}
|
||||
}
|
||||
|
||||
void Framebuffer::CircleFilled(int cx, int cy, int r, uint32_t c)
|
||||
{
|
||||
const int r2 = r * r;
|
||||
for (int y = -r; y <= r; ++y)
|
||||
for (int x = -r; x <= r; ++x)
|
||||
if (x * x + y * y <= r2) Put(cx + x, cy + y, c);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Аддитивные примитивы
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
void Framebuffer::AddBeam(float x0, float y0, float x1, float y1, uint32_t headCol,
|
||||
uint32_t tailCol, int halo)
|
||||
{
|
||||
// Отсечение по расширенному прямоугольнику кадра: длинный закадровый штрих
|
||||
// не должен крутить тысячи холостых итераций.
|
||||
const float pad = float(halo) + 2.0f;
|
||||
if ((x0 < -pad && x1 < -pad) || (x0 > float(w) + pad && x1 > float(w) + pad)) return;
|
||||
if ((y0 < -pad && y1 < -pad) || (y0 > float(h) + pad && y1 > float(h) + pad)) return;
|
||||
|
||||
const float dx = x1 - x0;
|
||||
const float dy = y1 - y0;
|
||||
const float len = std::sqrt(dx * dx + dy * dy);
|
||||
const int steps = int(len) + 1;
|
||||
if (steps > 4 * (w + h)) return; // защита от вырожденных значений
|
||||
|
||||
const float inv = 1.0f / float(steps);
|
||||
for (int i = 0; i <= steps; ++i)
|
||||
{
|
||||
const float t = float(i) * inv; // 0 = хвост, 1 = голова
|
||||
const float px_ = x0 + dx * t;
|
||||
const float py_ = y0 + dy * t;
|
||||
|
||||
// Цвет плывёт от хвоста к голове; хвост дополнительно гаснет.
|
||||
const uint32_t core = AddSat(ScaleColor8(tailCol, uint32_t((1.0f - t) * 256.0f)),
|
||||
ScaleColor8(headCol, uint32_t(t * 256.0f)));
|
||||
const int ix = int(px_);
|
||||
const int iy = int(py_);
|
||||
AddPx(ix, iy, core);
|
||||
|
||||
// Боковой ореол: та же линия вполсилы на соседних пикселях.
|
||||
if (halo > 0)
|
||||
{
|
||||
const uint32_t glow = ScaleColor8(core, 96);
|
||||
AddPx(ix + 1, iy, glow);
|
||||
AddPx(ix - 1, iy, glow);
|
||||
AddPx(ix, iy + 1, glow);
|
||||
AddPx(ix, iy - 1, glow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Framebuffer::AddGlowDisc(int cx, int cy, int radius, uint32_t color)
|
||||
{
|
||||
if (radius <= 0) return;
|
||||
if (cx + radius < 0 || cx - radius >= w || cy + radius < 0 || cy - radius >= h) return;
|
||||
|
||||
const float inv = 1.0f / float(radius);
|
||||
for (int y = -radius; y <= radius; ++y)
|
||||
{
|
||||
for (int x = -radius; x <= radius; ++x)
|
||||
{
|
||||
const float d = std::sqrt(float(x * x + y * y)) * inv;
|
||||
if (d >= 1.0f) continue;
|
||||
const float k = (1.0f - d) * (1.0f - d); // квадратичное затухание
|
||||
AddPx(cx + x, cy + y, ScaleColor8(color, uint32_t(k * 256.0f)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
void ScreenBlitter::Init(int width, int height)
|
||||
{
|
||||
Image img = GenImageColor(width, height, BLANK);
|
||||
ImageFormat(&img, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8);
|
||||
|
||||
Texture2D* t = new Texture2D(LoadTextureFromImage(img));
|
||||
UnloadImage(img);
|
||||
SetTextureFilter(*t, TEXTURE_FILTER_POINT);
|
||||
tex = t;
|
||||
texW = width;
|
||||
texH = height;
|
||||
}
|
||||
|
||||
void ScreenBlitter::Shutdown()
|
||||
{
|
||||
if (!tex) return;
|
||||
Texture2D* t = static_cast<Texture2D*>(tex);
|
||||
UnloadTexture(*t);
|
||||
delete t;
|
||||
tex = nullptr;
|
||||
}
|
||||
|
||||
void ScreenBlitter::Present(const Framebuffer& fb, int windowW, int windowH) const
|
||||
{
|
||||
if (!tex) return;
|
||||
if (fb.w != texW || fb.h != texH) return; // размер разошёлся — не грузим мусор
|
||||
Texture2D* t = static_cast<Texture2D*>(tex);
|
||||
UpdateTexture(*t, fb.px.data());
|
||||
|
||||
const Rectangle src = {0.0f, 0.0f, float(fb.w), float(fb.h)};
|
||||
const Rectangle dst = {0.0f, 0.0f, float(windowW), float(windowH)};
|
||||
DrawTexturePro(*t, src, dst, {0.0f, 0.0f}, 0.0f, WHITE);
|
||||
}
|
||||
192
src/render/framebuffer.h
Normal file
192
src/render/framebuffer.h
Normal file
@ -0,0 +1,192 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Софтверный фреймбуфер: RGBA8, ручная запись пикселей.
|
||||
// Ни один игровой пиксель не рисуется примитивами raylib.
|
||||
// raylib используется только в ScreenBlitter — финальный блит на экран.
|
||||
//
|
||||
// Инварианты (нарушение любого ломает картинку молча):
|
||||
// * альфа фреймбуфера всегда 255;
|
||||
// * ступень 0 световой рампы даёт чистый чёрный и совпадает с цветом Clear;
|
||||
// * спрайты пишутся с альфа-тестом, эффекты — аддитивно.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
// В памяти байты идут R,G,B,A (PIXELFORMAT_UNCOMPRESSED_R8G8B8A8),
|
||||
// значит little-endian uint32 = A<<24 | B<<16 | G<<8 | R.
|
||||
constexpr uint32_t RGBA(uint8_t r, uint8_t g, uint8_t b, uint8_t a = 255)
|
||||
{
|
||||
return uint32_t(r) | (uint32_t(g) << 8) | (uint32_t(b) << 16) | (uint32_t(a) << 24);
|
||||
}
|
||||
|
||||
constexpr uint8_t AlphaOf(uint32_t c) { return uint8_t(c >> 24); }
|
||||
|
||||
// Цвет для аддитивных эффектов: альфа 0, чтобы такой цвет нельзя было случайно
|
||||
// использовать как непрозрачный пиксель спрайта.
|
||||
constexpr uint32_t FXC(uint8_t r, uint8_t g, uint8_t b) { return RGBA(r, g, b, 0); }
|
||||
|
||||
// Сложение цветов с насыщением по каждому каналу.
|
||||
inline uint32_t AddSat(uint32_t a, uint32_t b)
|
||||
{
|
||||
uint32_t r = (a & 0xFFu) + (b & 0xFFu);
|
||||
uint32_t g = ((a >> 8) & 0xFFu) + ((b >> 8) & 0xFFu);
|
||||
uint32_t bl = ((a >> 16) & 0xFFu) + ((b >> 16) & 0xFFu);
|
||||
if (r > 255u) r = 255u;
|
||||
if (g > 255u) g = 255u;
|
||||
if (bl > 255u) bl = 255u;
|
||||
return 0xFF000000u | r | (g << 8) | (bl << 16);
|
||||
}
|
||||
|
||||
// Умножение цвета на коэффициент в формате 8.8 (256 == x1.0).
|
||||
inline uint32_t ScaleColor8(uint32_t c, uint32_t k256)
|
||||
{
|
||||
uint32_t r = ((c & 0xFFu) * k256) >> 8;
|
||||
uint32_t g = (((c >> 8) & 0xFFu) * k256) >> 8;
|
||||
uint32_t bl = (((c >> 16) & 0xFFu) * k256) >> 8;
|
||||
if (r > 255u) r = 255u;
|
||||
if (g > 255u) g = 255u;
|
||||
if (bl > 255u) bl = 255u;
|
||||
return 0xFF000000u | r | (g << 8) | (bl << 16);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Световая рампа (по мотивам COLORMAP из Doom).
|
||||
//
|
||||
// Свет — одна скалярная величина на точку мира. Таблица переводит её в цвет:
|
||||
// в полумраке картинка уходит в холодный синий, на свету нейтральна, во вспышке
|
||||
// выбивается в тёплый пересвет. На пиксель остаются три выборки из таблицы
|
||||
// и ноль умножений.
|
||||
// -----------------------------------------------------------------------------
|
||||
constexpr int RAMP_LEVELS = 32;
|
||||
|
||||
extern uint8_t g_shadeLut[RAMP_LEVELS][3][256];
|
||||
|
||||
// Перепечь таблицу. Вызывать при изменении гаммы/усиления, иначе ручки молчат.
|
||||
void BuildShadeRamp(float gamma, float maxGain);
|
||||
|
||||
// Упорядоченный дизеринг: 32 ступени рампы выглядят как ~200, полос не видно.
|
||||
extern const uint8_t BAYER4[4][4];
|
||||
|
||||
inline uint32_t ShadePixel(uint32_t c, int lvl)
|
||||
{
|
||||
const uint8_t* lr = g_shadeLut[lvl][0];
|
||||
const uint8_t* lg = g_shadeLut[lvl][1];
|
||||
const uint8_t* lb = g_shadeLut[lvl][2];
|
||||
return 0xFF000000u | uint32_t(lr[c & 0xFFu]) | (uint32_t(lg[(c >> 8) & 0xFFu]) << 8) |
|
||||
(uint32_t(lb[(c >> 16) & 0xFFu]) << 16);
|
||||
}
|
||||
|
||||
// Билинейный патч света по спрайту: L(px,py) = a + b*px + c*py + e*px^2 + f*py^2.
|
||||
// Такая форма — это ТОЧНАЯ билинейка по четырём углам ромба тайла, записанная
|
||||
// в координатах спрайта; поэтому на общем ребре соседних тайлов значения
|
||||
// совпадают и швов нет.
|
||||
struct ShadeQuad
|
||||
{
|
||||
float a = 1.0f;
|
||||
float b = 0.0f;
|
||||
float c = 0.0f;
|
||||
float e = 0.0f;
|
||||
float f = 0.0f;
|
||||
// Строки ниже clampRow светятся как clampRow: вертикальные грани стены
|
||||
// берут свет нижней кромки своей крыши, а не экстраполяцию квадратичного
|
||||
// члена на 32 строки вниз.
|
||||
int clampRow = 1 << 24;
|
||||
|
||||
static ShadeQuad Flat(float level)
|
||||
{
|
||||
ShadeQuad q;
|
||||
q.a = level;
|
||||
return q;
|
||||
}
|
||||
};
|
||||
|
||||
// Спрайт с альфа-тестом: px[i] с alpha==0 не пишется. Блендинга нет.
|
||||
// (ax, ay) — точка привязки внутри спрайта: она совмещается с экранной
|
||||
// проекцией мировой позиции сущности.
|
||||
struct Sprite
|
||||
{
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
int ax = 0;
|
||||
int ay = 0;
|
||||
std::vector<uint32_t> px;
|
||||
|
||||
void Init(int width, int height, uint32_t fill = 0)
|
||||
{
|
||||
w = width;
|
||||
h = height;
|
||||
px.assign(size_t(w) * size_t(h), fill);
|
||||
}
|
||||
void Set(int x, int y, uint32_t c)
|
||||
{
|
||||
if (unsigned(x) < unsigned(w) && unsigned(y) < unsigned(h)) px[size_t(y) * w + x] = c;
|
||||
}
|
||||
uint32_t Get(int x, int y) const
|
||||
{
|
||||
if (unsigned(x) < unsigned(w) && unsigned(y) < unsigned(h)) return px[size_t(y) * w + x];
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
struct Framebuffer
|
||||
{
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
std::vector<uint32_t> px;
|
||||
|
||||
void Init(int width, int height);
|
||||
void Clear(uint32_t color);
|
||||
|
||||
inline void Put(int x, int y, uint32_t c)
|
||||
{
|
||||
if (unsigned(x) < unsigned(w) && unsigned(y) < unsigned(h)) px[size_t(y) * w + x] = c;
|
||||
}
|
||||
|
||||
// Блит с альфа-тестом; (x, y) — куда попадёт точка привязки спрайта.
|
||||
void Blit(const Sprite& s, int x, int y);
|
||||
// Тот же блит, но каждый непрозрачный пиксель заменяется на tint (для силуэтов).
|
||||
void BlitTinted(const Sprite& s, int x, int y, uint32_t tint);
|
||||
// Блит со светом: цвет каждого пикселя проходит через рампу.
|
||||
void BlitShaded(const Sprite& s, int x, int y, const ShadeQuad& q);
|
||||
// Умножение фона под маской спрайта (контактные тени, лужи).
|
||||
void BlitMul(const Sprite& mask, int x, int y, uint32_t k256);
|
||||
|
||||
void FillRect(int x, int y, int rw, int rh, uint32_t c);
|
||||
void Line(int x0, int y0, int x1, int y1, uint32_t c);
|
||||
void CircleOutline(int cx, int cy, int r, uint32_t c);
|
||||
void CircleFilled(int cx, int cy, int r, uint32_t c);
|
||||
|
||||
// --- аддитивные примитивы для эффектов ---
|
||||
|
||||
inline void AddPx(int x, int y, uint32_t c)
|
||||
{
|
||||
if (unsigned(x) < unsigned(w) && unsigned(y) < unsigned(h))
|
||||
{
|
||||
uint32_t& d = px[size_t(y) * w + x];
|
||||
d = AddSat(d, c);
|
||||
}
|
||||
}
|
||||
|
||||
// Светящийся штрих: яркое ядро + боковой ореол, цвет плывёт от head к tail.
|
||||
void AddBeam(float x0, float y0, float x1, float y1, uint32_t headCol, uint32_t tailCol,
|
||||
int halo);
|
||||
// Мягкое пятно света (вспышка у дула, искра попадания).
|
||||
void AddGlowDisc(int cx, int cy, int radius, uint32_t color);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Финальный вывод: UpdateTexture + DrawTexturePro, фильтрация POINT.
|
||||
// Единственное место рендера, знающее про raylib.
|
||||
// -----------------------------------------------------------------------------
|
||||
struct ScreenBlitter
|
||||
{
|
||||
void Init(int width, int height);
|
||||
void Shutdown();
|
||||
void Present(const Framebuffer& fb, int windowW, int windowH) const;
|
||||
|
||||
private:
|
||||
void* tex = nullptr; // Texture2D на куче — чтобы не тащить raylib.h в заголовок
|
||||
int texW = 0;
|
||||
int texH = 0;
|
||||
};
|
||||
283
src/render/fx.cpp
Normal file
283
src/render/fx.cpp
Normal file
@ -0,0 +1,283 @@
|
||||
#include "render/fx.h"
|
||||
|
||||
#include "render/sprites.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Цвета эффектов: аддитивные, поэтому альфа 0 (см. FXC).
|
||||
constexpr uint32_t COL_TRACER_TAIL = FXC(120, 70, 20);
|
||||
constexpr uint32_t COL_MUZZLE = FXC(255, 225, 150);
|
||||
constexpr uint32_t COL_SPARK_WALL = FXC(255, 190, 90);
|
||||
constexpr uint32_t COL_SPARK_FLESH = FXC(220, 40, 40);
|
||||
constexpr uint32_t COL_SPARK_KILL = FXC(255, 120, 60);
|
||||
// Пурпурная искра — не украшение, а индикатор: попадание по своему.
|
||||
// В норме её не должно появиться НИ РАЗУ (критерий приёмки 11.2).
|
||||
constexpr uint32_t COL_SPARK_ALLY = FXC(255, 60, 255);
|
||||
// Ближний бой: холодная сталь на замахе, раскалённый след на ударе.
|
||||
constexpr uint32_t COL_BLADE_COLD = FXC(70, 110, 165);
|
||||
constexpr uint32_t COL_BLADE_EDGE = FXC(255, 250, 235);
|
||||
constexpr uint32_t COL_BLADE_TAIL = FXC(120, 165, 235);
|
||||
constexpr uint32_t COL_SPARK_MELEE = FXC(255, 165, 95);
|
||||
// Оборванный взмах — тусклая серая вспышка: свой влез под лезвие.
|
||||
constexpr uint32_t COL_SPARK_ABORT = FXC(150, 150, 170);
|
||||
|
||||
inline Vec2 ToScreen(Vec2 world, int ox, int oy, int liftPx)
|
||||
{
|
||||
const Vec2 s = iso::WorldToScreen(world);
|
||||
return Vec2{s.x + float(ox), s.y + float(oy) - float(liftPx)};
|
||||
}
|
||||
|
||||
// Смешать два аддитивных цвета.
|
||||
inline uint32_t MixFx(uint32_t a, uint32_t b, float t)
|
||||
{
|
||||
const uint32_t k = uint32_t(Clampf(t, 0.0f, 1.0f) * 256.0f);
|
||||
return AddSat(ScaleColor8(a, 256u - k), ScaleColor8(b, k));
|
||||
}
|
||||
|
||||
// Дуга в мировых координатах, спроецированная по точкам: в изометрии круг —
|
||||
// это эллипс, и рисовать его надо сэмплами, а не экранной окружностью.
|
||||
// Цвет плывёт от хвоста (colFrom) к голове (colTo) — так читается направление.
|
||||
void AddWorldArc(Framebuffer& fb, Vec2 center, float radius, float angFrom, float angTo,
|
||||
int ox, int oy, int lift, uint32_t colFrom, uint32_t colTo, int halo)
|
||||
{
|
||||
constexpr int SEG = 12;
|
||||
Vec2 prev = ToScreen(center + FromAngle(angFrom) * radius, ox, oy, lift);
|
||||
|
||||
for (int k = 1; k <= SEG; ++k)
|
||||
{
|
||||
const float t = float(k) / float(SEG);
|
||||
const Vec2 cur = ToScreen(center + FromAngle(angFrom + (angTo - angFrom) * t) * radius,
|
||||
ox, oy, lift);
|
||||
fb.AddBeam(prev.x, prev.y, cur.x, cur.y, MixFx(colFrom, colTo, t),
|
||||
MixFx(colFrom, colTo, t - 1.0f / float(SEG)), halo);
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void FxSystem::Reset()
|
||||
{
|
||||
flashCount = 0;
|
||||
sparkCount = 0;
|
||||
eventCursor = 0;
|
||||
shotShake = 0.0f;
|
||||
}
|
||||
|
||||
void FxSystem::Ingest(const CombatEventLog& log, const Agent* agents, int agentCount)
|
||||
{
|
||||
uint32_t from = eventCursor;
|
||||
if (log.head > from + COMBAT_EVENT_CAP) from = log.head - COMBAT_EVENT_CAP;
|
||||
|
||||
for (uint32_t i = from; i < log.head; ++i)
|
||||
{
|
||||
const CombatEvent& e = log.buf[i % COMBAT_EVENT_CAP];
|
||||
|
||||
if (e.kind == CombatEventKind::SHOT)
|
||||
{
|
||||
// Вид выстрела — свойство ствола, а не события: журнал носит только
|
||||
// «кто, откуда, куда», а как это выглядит, знает оружие стрелка.
|
||||
const bool known = (e.owner >= 0 && e.owner < agentCount);
|
||||
const FirearmDef def = known ? agents[e.owner].gun.def : FirearmDef{};
|
||||
|
||||
shotShake += def.shakePerShot;
|
||||
|
||||
if (flashCount < FX_FLASH_MAX)
|
||||
{
|
||||
FxFlash f;
|
||||
f.pos = e.pos;
|
||||
f.dir = e.dir;
|
||||
f.life = def.muzzleTime;
|
||||
f.maxLife = def.muzzleTime;
|
||||
f.radiusPx = def.muzzleRadiusPx;
|
||||
f.flamePx = def.muzzleFlamePx;
|
||||
f.color = FXC(def.muzzleR, def.muzzleG, def.muzzleB);
|
||||
f.owner = e.owner;
|
||||
flashes[flashCount++] = f;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
uint32_t col = 0;
|
||||
float power = 1.0f;
|
||||
switch (e.kind)
|
||||
{
|
||||
case CombatEventKind::HIT_WALL: col = COL_SPARK_WALL; power = 1.0f; break;
|
||||
case CombatEventKind::HIT_TARGET: col = COL_SPARK_FLESH; power = 0.8f; break;
|
||||
case CombatEventKind::HIT_KILL: col = COL_SPARK_KILL; power = 1.8f; break;
|
||||
case CombatEventKind::HIT_ALLY: col = COL_SPARK_ALLY; power = 2.5f; break;
|
||||
// Удар клинком — самое громкое событие в кадре: он всегда в упор.
|
||||
case CombatEventKind::MELEE_HIT: col = COL_SPARK_MELEE; power = 2.2f; break;
|
||||
case CombatEventKind::MELEE_ABORT: col = COL_SPARK_ABORT; power = 0.7f; break;
|
||||
default: continue;
|
||||
}
|
||||
if (sparkCount < FX_SPARK_MAX)
|
||||
sparks[sparkCount++] = FxSpark{e.pos, e.dir, g_tune.sparkTime, power, col};
|
||||
}
|
||||
eventCursor = log.head;
|
||||
}
|
||||
|
||||
void FxSystem::Update(float frameDt)
|
||||
{
|
||||
for (int i = 0; i < flashCount;)
|
||||
{
|
||||
flashes[i].life -= frameDt;
|
||||
if (flashes[i].life <= 0.0f) flashes[i] = flashes[--flashCount];
|
||||
else ++i;
|
||||
}
|
||||
for (int i = 0; i < sparkCount;)
|
||||
{
|
||||
sparks[i].life -= frameDt;
|
||||
if (sparks[i].life <= 0.0f) sparks[i] = sparks[--sparkCount];
|
||||
else ++i;
|
||||
}
|
||||
}
|
||||
|
||||
void FxSystem::Draw(Framebuffer& fb, const std::vector<Bullet>& bullets, int ox, int oy,
|
||||
float alpha) const
|
||||
{
|
||||
const int lift = int(g_tune.bulletLiftPx);
|
||||
const uint32_t inten = uint32_t(Clampf(g_tune.fxIntensity, 0.0f, 4.0f) * 256.0f);
|
||||
|
||||
// --- трассеры ------------------------------------------------------------
|
||||
// Длина и цвет штриха приехали вместе со снарядом: у пистолета трассер
|
||||
// короче и теплее, чем у карабина, и это видно без единой подписи.
|
||||
for (const Bullet& b : bullets)
|
||||
{
|
||||
// Голова — интерполированная позиция кадра, хвост — на tracerLength
|
||||
// назад по вектору движения, но не дальше точки вылета.
|
||||
const Vec2 head = Lerp(b.prevPos, b.pos, alpha);
|
||||
const Vec2 dir = Normalized(b.vel);
|
||||
const float flown = Distance(b.origin, head);
|
||||
const float tailLen = std::min(b.tracerLength, flown);
|
||||
const Vec2 tail = head - dir * tailLen;
|
||||
|
||||
const Vec2 hs = ToScreen(head, ox, oy, lift);
|
||||
const Vec2 ts = ToScreen(tail, ox, oy, lift);
|
||||
|
||||
const uint32_t headCol = ScaleColor8(FXC(b.tracerR, b.tracerG, b.tracerB), inten);
|
||||
fb.AddBeam(ts.x, ts.y, hs.x, hs.y, headCol, ScaleColor8(COL_TRACER_TAIL, inten), 1);
|
||||
// Само тело пули — яркая точка на голове штриха.
|
||||
fb.AddGlowDisc(int(hs.x), int(hs.y), 2, headCol);
|
||||
}
|
||||
|
||||
// --- вспышки у дула ------------------------------------------------------
|
||||
for (int i = 0; i < flashCount; ++i)
|
||||
{
|
||||
const FxFlash& f = flashes[i];
|
||||
const float t = Clampf(f.life / std::max(0.001f, f.maxLife), 0.0f, 1.0f);
|
||||
const float amp = t * t;
|
||||
|
||||
const Vec2 s = ToScreen(f.pos, ox, oy, lift);
|
||||
const uint32_t c = ScaleColor8(f.color ? f.color : COL_MUZZLE,
|
||||
uint32_t(amp * float(inten)));
|
||||
|
||||
fb.AddGlowDisc(int(s.x), int(s.y), int(f.radiusPx * amp) + 1, c);
|
||||
// Короткий язык пламени по направлению выстрела.
|
||||
const Vec2 tip = ToScreen(f.pos + f.dir * f.flamePx, ox, oy, lift);
|
||||
fb.AddBeam(s.x, s.y, tip.x, tip.y, c, ScaleColor8(c, 128), 1);
|
||||
}
|
||||
|
||||
// --- искры попаданий ------------------------------------------------------
|
||||
for (int i = 0; i < sparkCount; ++i)
|
||||
{
|
||||
const FxSpark& sp = sparks[i];
|
||||
const float t = Clampf(sp.life / std::max(0.001f, g_tune.sparkTime), 0.0f, 1.0f);
|
||||
const float amp = t * t * sp.power;
|
||||
|
||||
const Vec2 s = ToScreen(sp.pos, ox, oy, lift);
|
||||
const uint32_t c = ScaleColor8(sp.color, uint32_t(amp * float(inten)));
|
||||
fb.AddGlowDisc(int(s.x), int(s.y), int(g_tune.sparkRadiusPx * amp) + 1, c);
|
||||
|
||||
// Разлёт осколков назад от направления удара — детерминированно по индексу.
|
||||
const int shards = 4;
|
||||
for (int k = 0; k < shards; ++k)
|
||||
{
|
||||
const float ang = AngleOf(sp.dir) + PI_F + (float(k) - 1.5f) * 0.5f;
|
||||
const float len = (1.0f - t) * 0.45f;
|
||||
const Vec2 e = ToScreen(sp.pos + FromAngle(ang) * len, ox, oy, lift);
|
||||
fb.AddBeam(s.x, s.y, e.x, e.y, c, 0u, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DrawMeleeArcs(Framebuffer& fb, const Agent* agents, int count, int ox, int oy, float alpha)
|
||||
{
|
||||
const int lift = int(g_tune.bladeLiftPx);
|
||||
const uint32_t inten = uint32_t(Clampf(g_tune.fxIntensity, 0.0f, 4.0f) * 256.0f);
|
||||
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
const Agent& a = agents[i];
|
||||
if (!a.IsMelee()) continue;
|
||||
|
||||
const MeleeWeapon& m = a.melee;
|
||||
if (m.state == MeleeState::IDLE) continue;
|
||||
|
||||
const Vec2 p = Lerp(a.prevPos, a.pos, alpha);
|
||||
const float half = m.HalfArc();
|
||||
const float start = WrapPi(m.dir - m.sweepSign * half); // откуда заходит лезвие
|
||||
|
||||
if (m.state == MeleeState::WINDUP)
|
||||
{
|
||||
// Телеграф: клинок уводится НАЗАД за край сектора и разгорается.
|
||||
// Игрок обязан успеть прочитать замах — иначе удар бьёт «из ниоткуда»
|
||||
// и ощущается не как удар, а как случайное списание HP.
|
||||
const float t = m.Progress();
|
||||
const float back = WrapPi(start - m.sweepSign * (0.4f * half * t));
|
||||
const uint32_t c = ScaleColor8(COL_BLADE_COLD, uint32_t(inten * (0.2f + 0.8f * t)));
|
||||
|
||||
const Vec2 near_ = ToScreen(p + FromAngle(back) * (m.reach * 0.40f), ox, oy, lift);
|
||||
const Vec2 far_ = ToScreen(p + FromAngle(back) * (m.reach * 0.95f), ox, oy, lift);
|
||||
|
||||
AddWorldArc(fb, p, m.reach * 0.9f, start, back, ox, oy, lift, 0u, c, 0);
|
||||
fb.AddBeam(near_.x, near_.y, far_.x, far_.y, c, ScaleColor8(c, 64), 0);
|
||||
fb.AddGlowDisc(int(far_.x), int(far_.y), 1 + int(2.0f * t), c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// STRIKE и RECOVER рисуются одним серпом: в ударе он растёт до текущего
|
||||
// угла лезвия, после удара — стоит на месте и гаснет.
|
||||
float prog = 1.0f;
|
||||
float fade = 1.0f;
|
||||
if (m.state == MeleeState::STRIKE)
|
||||
{
|
||||
prog = m.Progress();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Затухание быстрое: след клинка не должен висеть до следующего замаха.
|
||||
fade = 1.0f - Clampf(m.Progress() * 2.2f, 0.0f, 1.0f);
|
||||
if (fade <= 0.0f) continue;
|
||||
}
|
||||
|
||||
const float bladeAng = m.BladeAngleAt(prog);
|
||||
// Хвост — не весь пройденный путь, а его доля: серп, а не кольцо.
|
||||
const float tailProg = std::max(0.0f, prog - g_tune.bladeTrailFrac);
|
||||
const float tailAng = m.BladeAngleAt(tailProg);
|
||||
|
||||
const uint32_t k = uint32_t(inten * fade);
|
||||
|
||||
// Серп набирается слоями концентрических дуг: внешний край раскалён,
|
||||
// внутренние тускнеют к рукояти. На 480x270 одна линия читается палкой,
|
||||
// а четыре — взмахом.
|
||||
constexpr int LAYERS = 4;
|
||||
for (int l = 0; l < LAYERS; ++l)
|
||||
{
|
||||
const float f = float(l) / float(LAYERS - 1);
|
||||
const float rr = m.reach * Lerpf(0.42f, 1.02f, f);
|
||||
const uint32_t hot = ScaleColor8(COL_BLADE_EDGE, uint32_t(k * (0.25f + 0.75f * f)));
|
||||
const uint32_t cold = ScaleColor8(COL_BLADE_TAIL, uint32_t(k * 0.18f * (0.4f + f)));
|
||||
AddWorldArc(fb, p, rr, tailAng, bladeAng, ox, oy, lift, cold, hot,
|
||||
(l == LAYERS - 1) ? 1 : 0);
|
||||
}
|
||||
|
||||
// Само лезвие: раскалённая радиальная черта на голове серпа.
|
||||
const Vec2 inner = ToScreen(p + FromAngle(bladeAng) * (m.reach * 0.38f), ox, oy, lift);
|
||||
const Vec2 tip = ToScreen(p + FromAngle(bladeAng) * (m.reach * 1.04f), ox, oy, lift);
|
||||
const uint32_t edge = ScaleColor8(COL_BLADE_EDGE, k);
|
||||
fb.AddBeam(inner.x, inner.y, tip.x, tip.y, edge, ScaleColor8(COL_BLADE_TAIL, k / 2u), 1);
|
||||
fb.AddGlowDisc(int(tip.x), int(tip.y), 3, edge);
|
||||
}
|
||||
}
|
||||
75
src/render/fx.h
Normal file
75
src/render/fx.h
Normal file
@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Визуал стрельбы: трассеры, вспышки у дула, искры попаданий.
|
||||
// Render-only. Физику пули не трогает вообще — снаряд по-прежнему летит ровно
|
||||
// по проверенному отрезку, правило «не стрелять сквозь своих» не задето.
|
||||
//
|
||||
// Из чего состоит трассер (как это выглядит в CS2, перенесённое на 480x270):
|
||||
// * вытянутый штрих вдоль вектора движения, а не точка;
|
||||
// * яркое ядро + тусклый боковой ореол, затухание к хвосту;
|
||||
// * короткая вспышка у дула в момент выстрела;
|
||||
// * искра в точке попадания, разная для стены и для цели.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "core/math.h"
|
||||
#include "render/framebuffer.h"
|
||||
#include "render/iso.h"
|
||||
#include "sim/agent.h"
|
||||
#include "sim/bullet.h"
|
||||
#include "sim/events.h"
|
||||
|
||||
constexpr int FX_FLASH_MAX = 32;
|
||||
constexpr int FX_SPARK_MAX = 64;
|
||||
|
||||
struct FxFlash // вспышка у дула
|
||||
{
|
||||
Vec2 pos{};
|
||||
Vec2 dir{};
|
||||
float life = 0.0f;
|
||||
float maxLife = 0.07f; // всё, что описывает вид выстрела, приезжает
|
||||
float radiusPx = 5.0f; // с оружия стрелявшего: у пистолета вспышка мельче
|
||||
float flamePx = 0.35f;
|
||||
uint32_t color = 0;
|
||||
int owner = 0;
|
||||
};
|
||||
|
||||
struct FxSpark // искра попадания
|
||||
{
|
||||
Vec2 pos{};
|
||||
Vec2 dir{};
|
||||
float life = 0.0f;
|
||||
float power = 1.0f;
|
||||
uint32_t color = 0;
|
||||
};
|
||||
|
||||
struct FxSystem
|
||||
{
|
||||
FxFlash flashes[FX_FLASH_MAX];
|
||||
int flashCount = 0;
|
||||
FxSpark sparks[FX_SPARK_MAX];
|
||||
int sparkCount = 0;
|
||||
uint32_t eventCursor = 0;
|
||||
|
||||
// Отдача камеры за кадр: считается здесь, потому что только здесь известно,
|
||||
// из ЧЕГО стреляли. Потребитель (Dread) забирает и обнуляет.
|
||||
float shotShake = 0.0f;
|
||||
|
||||
void Reset();
|
||||
// agents нужны ровно для одного: взять визуал выстрела с оружия стрелка.
|
||||
void Ingest(const CombatEventLog& log, const Agent* agents, int agentCount);
|
||||
void Update(float frameDt);
|
||||
|
||||
// Рисуется ПОСЛЕ сортированной сцены и ДО пост-прохода: эффекты светятся
|
||||
// поверх геометрии, но подчиняются общей цветокоррекции кадра.
|
||||
void Draw(Framebuffer& fb, const std::vector<Bullet>& bullets, int ox, int oy, float alpha) const;
|
||||
};
|
||||
|
||||
// Дуга замаха и удара. Читается ПРЯМО из состояния агентов, а не из журнала:
|
||||
// фаза живёт десятые доли секунды, её видно в каждом кадре, и привязка к живой
|
||||
// позиции бойца важнее — он в момент удара едет вперёд рывком.
|
||||
//
|
||||
// Что рисуется:
|
||||
// WINDUP — холодная тонкая дуга у дальнего края сектора, разгорается;
|
||||
// STRIKE — раскалённый серп от начала взмаха до текущего угла лезвия;
|
||||
// RECOVER — то, что осталось от серпа, гаснет.
|
||||
void DrawMeleeArcs(Framebuffer& fb, const Agent* agents, int count, int ox, int oy, float alpha);
|
||||
|
||||
93
src/render/iso.h
Normal file
93
src/render/iso.h
Normal file
@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Изометрия существует ТОЛЬКО здесь и в рендере. Симуляция, ИИ и коллизии
|
||||
// работают в декартовом мире (top-down) и об этом файле не знают.
|
||||
//
|
||||
// sx = (wx - wy) * 16
|
||||
// sy = (wx + wy) * 8
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
#include "core/math.h"
|
||||
#include "render/framebuffer.h"
|
||||
#include "tuning.h"
|
||||
|
||||
namespace iso
|
||||
{
|
||||
|
||||
inline Vec2 WorldToScreen(Vec2 w)
|
||||
{
|
||||
return {(w.x - w.y) * float(TILE_HALF_W), (w.x + w.y) * float(TILE_HALF_H)};
|
||||
}
|
||||
|
||||
inline Vec2 ScreenToWorld(Vec2 s)
|
||||
{
|
||||
const float a = s.x / float(TILE_W); // = (wx - wy) / 2
|
||||
const float b = s.y / float(TILE_H); // = (wx + wy) / 2
|
||||
return {b + a, b - a};
|
||||
}
|
||||
|
||||
// Ключ сортировки по глубине: чем больше, тем «ближе к камере».
|
||||
inline float Depth(Vec2 w) { return w.x + w.y; }
|
||||
|
||||
// Направление в ЭКРАННЫХ осях -> направление в мире (WASD, спек 5).
|
||||
inline Vec2 ScreenDirToWorld(Vec2 screenDir)
|
||||
{
|
||||
return Normalized(ScreenToWorld(screenDir));
|
||||
}
|
||||
|
||||
// Камера следует за якорем со сглаживанием; смещение округляется до пикселя.
|
||||
struct Camera
|
||||
{
|
||||
Vec2 pos{};
|
||||
bool snapped = false;
|
||||
|
||||
void SnapTo(Vec2 target) { pos = target; snapped = true; }
|
||||
|
||||
void Follow(Vec2 target, float dt, float smooth)
|
||||
{
|
||||
if (!snapped) { SnapTo(target); return; }
|
||||
const float k = 1.0f - std::exp(-smooth * dt);
|
||||
pos = Lerp(pos, target, k);
|
||||
}
|
||||
|
||||
void ScreenOffset(int& ox, int& oy) const
|
||||
{
|
||||
const Vec2 c = WorldToScreen(pos);
|
||||
ox = int(std::lround(INTERNAL_W * 0.5f - c.x));
|
||||
oy = int(std::lround(INTERNAL_H * 0.5f - c.y));
|
||||
}
|
||||
};
|
||||
|
||||
// Один элемент списка отрисовки: и стены, и сущности идут в общий список.
|
||||
struct DrawItem
|
||||
{
|
||||
float depth = 0.0f;
|
||||
const Sprite* spr = nullptr;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
uint32_t tint = 0;
|
||||
bool hasTint = false; // явный флаг: RGBA(0,0,0,0) — легальный цвет
|
||||
bool shaded = false; // рисовать через световую рампу
|
||||
ShadeQuad quad{};
|
||||
};
|
||||
|
||||
inline void SortByDepth(std::vector<DrawItem>& items)
|
||||
{
|
||||
std::stable_sort(items.begin(), items.end(),
|
||||
[](const DrawItem& a, const DrawItem& b) { return a.depth < b.depth; });
|
||||
}
|
||||
|
||||
inline void FlushDrawList(Framebuffer& fb, const std::vector<DrawItem>& items)
|
||||
{
|
||||
for (const DrawItem& it : items)
|
||||
{
|
||||
if (!it.spr) continue;
|
||||
if (it.hasTint) fb.BlitTinted(*it.spr, it.x, it.y, it.tint);
|
||||
else if (it.shaded) fb.BlitShaded(*it.spr, it.x, it.y, it.quad);
|
||||
else fb.Blit(*it.spr, it.x, it.y);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace iso
|
||||
282
src/render/lighting.cpp
Normal file
282
src/render/lighting.cpp
Normal file
@ -0,0 +1,282 @@
|
||||
#include "render/lighting.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
inline float Smooth01(float t)
|
||||
{
|
||||
if (t <= 0.0f) return 0.0f;
|
||||
if (t >= 1.0f) return 1.0f;
|
||||
return t * t * (3.0f - 2.0f * t);
|
||||
}
|
||||
|
||||
// Плавное падение с 1 до 0 на отрезке [lo, hi].
|
||||
inline float FadeOut(float x, float lo, float hi)
|
||||
{
|
||||
if (hi <= lo) return x <= lo ? 1.0f : 0.0f;
|
||||
return 1.0f - Smooth01((x - lo) / (hi - lo));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Lighting::Reset()
|
||||
{
|
||||
for (int i = 0; i < MAP_W * MAP_H; ++i)
|
||||
{
|
||||
vis[i] = 0.0f;
|
||||
mem[i] = 0.0f;
|
||||
dyn[i] = 0.0f;
|
||||
tile[i] = 0.0f;
|
||||
}
|
||||
for (int i = 0; i < (MAP_W + 1) * (MAP_H + 1); ++i) corner[i] = 0.0f;
|
||||
flashCount = 0;
|
||||
eventCursor = 0;
|
||||
}
|
||||
|
||||
void Lighting::Ingest(const CombatEventLog& log, const Agent* agents, int agentCount)
|
||||
{
|
||||
// Кольцо: если за кадр событий больше ёмкости, старые честно потеряны.
|
||||
uint32_t from = eventCursor;
|
||||
if (log.head > from + COMBAT_EVENT_CAP) from = log.head - COMBAT_EVENT_CAP;
|
||||
|
||||
for (uint32_t i = from; i < log.head; ++i)
|
||||
{
|
||||
const CombatEvent& e = log.buf[i % COMBAT_EVENT_CAP];
|
||||
|
||||
// Параметры вспышки берутся с оружия стрелявшего, если оно известно.
|
||||
const bool ownerKnown = (e.owner >= 0 && e.owner < agentCount);
|
||||
const float ownPower = ownerKnown ? agents[e.owner].gun.def.flashPower : g_tune.flashPower;
|
||||
const float ownRadius = ownerKnown ? agents[e.owner].gun.def.flashRadius : g_tune.flashRadius;
|
||||
|
||||
float power = 0.0f;
|
||||
switch (e.kind)
|
||||
{
|
||||
case CombatEventKind::SHOT: power = ownPower; break;
|
||||
case CombatEventKind::HIT_WALL: power = g_tune.flashPower * 0.45f; break;
|
||||
case CombatEventKind::HIT_TARGET: power = g_tune.flashPower * 0.35f; break;
|
||||
case CombatEventKind::HIT_KILL: power = g_tune.flashPower * 0.9f; break;
|
||||
// Клинок не даёт дульной вспышки, но искры от удара в упор освещают
|
||||
// не хуже — иначе весь ближний бой идёт в кромешной темноте.
|
||||
case CombatEventKind::MELEE_SWING: power = g_tune.flashPower * 0.22f; break;
|
||||
case CombatEventKind::MELEE_HIT: power = g_tune.flashPower * 0.75f; break;
|
||||
default: break;
|
||||
}
|
||||
if (power <= 0.0f) continue;
|
||||
|
||||
if (flashCount < LIGHT_FLASH_MAX)
|
||||
flashes[flashCount++] = LightFlash{e.pos, power, g_tune.flashTime,
|
||||
(e.kind == CombatEventKind::SHOT)
|
||||
? ownRadius : g_tune.flashRadius};
|
||||
}
|
||||
eventCursor = log.head;
|
||||
}
|
||||
|
||||
void Lighting::Update(const Agent* agents, int count, const Tilemap& map, float dt)
|
||||
{
|
||||
static float visRaw[MAP_W * MAP_H];
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
// Аварийный тумблер: всё видно, память полная, вспышек нет.
|
||||
for (int i = 0; i < MAP_W * MAP_H; ++i) { vis[i] = 1.0f; mem[i] = 1.0f; dyn[i] = 0.0f; }
|
||||
for (int i = 0; i < MAP_W * MAP_H; ++i) tile[i] = 1.0f;
|
||||
for (int cy = 0; cy <= MAP_H; ++cy)
|
||||
for (int cx = 0; cx <= MAP_W; ++cx) corner[cy * (MAP_W + 1) + cx] = 1.0f;
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < MAP_W * MAP_H; ++i) visRaw[i] = 0.0f;
|
||||
|
||||
// --- 1. видимость пола от каждого агента ---------------------------------
|
||||
const float rFull = g_tune.sightFullRadius;
|
||||
const float rMax = std::max(rFull + 0.01f, g_tune.sightRadius);
|
||||
const float coneHalf = g_tune.sightConeDeg * 0.5f * DEG2RAD_F;
|
||||
const float coneSoft = g_tune.sightConeSoftDeg * DEG2RAD_F;
|
||||
const float personal = g_tune.sightPersonalRadius;
|
||||
|
||||
for (int a = 0; a < count; ++a)
|
||||
{
|
||||
const Agent& ag = agents[a];
|
||||
const int x0 = std::max(0, int(std::floor(ag.pos.x - rMax)));
|
||||
const int x1 = std::min(MAP_W - 1, int(std::floor(ag.pos.x + rMax)));
|
||||
const int y0 = std::max(0, int(std::floor(ag.pos.y - rMax)));
|
||||
const int y1 = std::min(MAP_H - 1, int(std::floor(ag.pos.y + rMax)));
|
||||
|
||||
for (int ty = y0; ty <= y1; ++ty)
|
||||
{
|
||||
for (int tx = x0; tx <= x1; ++tx)
|
||||
{
|
||||
if (map.IsWall(tx, ty)) continue; // стены освещаются отдельно
|
||||
|
||||
const Vec2 c{tx + 0.5f, ty + 0.5f};
|
||||
const Vec2 d = c - ag.pos;
|
||||
const float dist = Length(d);
|
||||
if (dist > rMax) continue;
|
||||
|
||||
const float fd = FadeOut(dist, rFull, rMax);
|
||||
if (fd <= 0.0f) continue;
|
||||
|
||||
// Конус по направлению взгляда + круговой «личный» ореол рядом.
|
||||
// max, а не сумма: ореол дырявит конус вблизи, но не удваивает свет.
|
||||
const float ang = std::fabs(AngleDiff(ag.facing, AngleOf(d)));
|
||||
const float fc = FadeOut(ang, coneHalf, coneHalf + coneSoft);
|
||||
const float fp = FadeOut(dist, personal * 0.6f, personal);
|
||||
const float v = fd * std::max(fc, fp);
|
||||
if (v <= 0.0f) continue;
|
||||
|
||||
const int idx = ty * MAP_W + tx;
|
||||
if (v <= visRaw[idx]) continue; // уже ярче — рейкаст не нужен
|
||||
if (map.RaycastBlocked(ag.pos, c)) continue; // стена перекрыла обзор
|
||||
visRaw[idx] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. свет протекает в стены ------------------------------------------
|
||||
// Стену нельзя проверить лучом до её центра: DDA считает попадание в саму
|
||||
// стену перекрытием. Поэтому стена берёт максимум от соседей.
|
||||
for (int pass = 0; pass < WALL_LIGHT_PASSES; ++pass)
|
||||
{
|
||||
for (int ty = 0; ty < MAP_H; ++ty)
|
||||
{
|
||||
for (int tx = 0; tx < MAP_W; ++tx)
|
||||
{
|
||||
if (!map.IsWall(tx, ty)) continue;
|
||||
float best = 0.0f;
|
||||
for (int oy = -1; oy <= 1; ++oy)
|
||||
for (int ox = -1; ox <= 1; ++ox)
|
||||
{
|
||||
if (ox == 0 && oy == 0) continue;
|
||||
const int nx = tx + ox, ny = ty + oy;
|
||||
if (!Tilemap::InBounds(nx, ny)) continue;
|
||||
best = std::max(best, visRaw[ny * MAP_W + nx]);
|
||||
}
|
||||
const int idx = ty * MAP_W + tx;
|
||||
visRaw[idx] = std::max(visRaw[idx], best * g_tune.fogWallBleed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. временное сглаживание, чтобы свет не щёлкал ----------------------
|
||||
const float k = 1.0f - std::exp(-g_tune.fogFadeRate * dt);
|
||||
for (int i = 0; i < MAP_W * MAP_H; ++i)
|
||||
vis[i] += (visRaw[i] - vis[i]) * k;
|
||||
|
||||
// --- 4. динамический свет от вспышек -------------------------------------
|
||||
for (int i = 0; i < MAP_W * MAP_H; ++i) dyn[i] = 0.0f;
|
||||
|
||||
for (int i = 0; i < flashCount;)
|
||||
{
|
||||
LightFlash& f = flashes[i];
|
||||
f.life -= dt;
|
||||
if (f.life <= 0.0f)
|
||||
{
|
||||
flashes[i] = flashes[--flashCount];
|
||||
continue;
|
||||
}
|
||||
|
||||
const float t = Clampf(f.life / std::max(0.001f, g_tune.flashTime), 0.0f, 1.0f);
|
||||
const float amp = f.power * t * t; // резкий спад, вспышка короткая
|
||||
const float rad = f.radius;
|
||||
|
||||
const int x0 = std::max(0, int(std::floor(f.pos.x - rad)));
|
||||
const int x1 = std::min(MAP_W - 1, int(std::floor(f.pos.x + rad)));
|
||||
const int y0 = std::max(0, int(std::floor(f.pos.y - rad)));
|
||||
const int y1 = std::min(MAP_H - 1, int(std::floor(f.pos.y + rad)));
|
||||
|
||||
for (int ty = y0; ty <= y1; ++ty)
|
||||
for (int tx = x0; tx <= x1; ++tx)
|
||||
{
|
||||
const Vec2 c{tx + 0.5f, ty + 0.5f};
|
||||
const float dist = Distance(f.pos, c);
|
||||
if (dist > rad) continue;
|
||||
const float fall = FadeOut(dist, rad * 0.15f, rad);
|
||||
if (fall <= 0.0f) continue;
|
||||
// Вспышка не светит сквозь стены (сама стена подсвечивается).
|
||||
if (!map.IsWall(tx, ty) && map.RaycastBlocked(f.pos, c)) continue;
|
||||
const int idx = ty * MAP_W + tx;
|
||||
dyn[idx] = std::max(dyn[idx], amp * fall);
|
||||
}
|
||||
++i;
|
||||
}
|
||||
|
||||
// --- 5. память карты и сборка итогового уровня ---------------------------
|
||||
for (int i = 0; i < MAP_W * MAP_H; ++i)
|
||||
{
|
||||
const float seen = vis[i];
|
||||
if (seen > 0.02f) mem[i] = std::max(mem[i], g_tune.fogMemoryLevel);
|
||||
|
||||
float lvl = std::max(mem[i], seen * g_tune.sightBrightness) + dyn[i];
|
||||
tile[i] = Clampf(lvl, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
// --- 6. узловая сетка: узел = среднее смежных тайлов ----------------------
|
||||
for (int cy = 0; cy <= MAP_H; ++cy)
|
||||
{
|
||||
for (int cx = 0; cx <= MAP_W; ++cx)
|
||||
{
|
||||
float sum = 0.0f;
|
||||
int n = 0;
|
||||
for (int oy = -1; oy <= 0; ++oy)
|
||||
for (int ox = -1; ox <= 0; ++ox)
|
||||
{
|
||||
const int tx = cx + ox, ty = cy + oy;
|
||||
if (!Tilemap::InBounds(tx, ty)) continue;
|
||||
sum += tile[ty * MAP_W + tx];
|
||||
++n;
|
||||
}
|
||||
corner[cy * (MAP_W + 1) + cx] = (n > 0) ? (sum / float(n)) : 0.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float Lighting::LightAt(Vec2 world) const
|
||||
{
|
||||
const float fx = Clampf(world.x, 0.0f, float(MAP_W) - 0.001f);
|
||||
const float fy = Clampf(world.y, 0.0f, float(MAP_H) - 0.001f);
|
||||
const int cx = int(fx);
|
||||
const int cy = int(fy);
|
||||
const float u = fx - float(cx);
|
||||
const float v = fy - float(cy);
|
||||
|
||||
const int stride = MAP_W + 1;
|
||||
const float c00 = corner[cy * stride + cx];
|
||||
const float c10 = corner[cy * stride + cx + 1];
|
||||
const float c01 = corner[(cy + 1) * stride + cx];
|
||||
const float c11 = corner[(cy + 1) * stride + cx + 1];
|
||||
|
||||
return c00 * (1 - u) * (1 - v) + c10 * u * (1 - v) + c01 * (1 - u) * v + c11 * u * v;
|
||||
}
|
||||
|
||||
float Lighting::MaxCornerOf(int tx, int ty) const
|
||||
{
|
||||
if (!Tilemap::InBounds(tx, ty)) return 0.0f;
|
||||
const int stride = MAP_W + 1;
|
||||
const float c00 = corner[ty * stride + tx];
|
||||
const float c10 = corner[ty * stride + tx + 1];
|
||||
const float c01 = corner[(ty + 1) * stride + tx];
|
||||
const float c11 = corner[(ty + 1) * stride + tx + 1];
|
||||
return std::max(std::max(c00, c10), std::max(c01, c11));
|
||||
}
|
||||
|
||||
ShadeQuad Lighting::QuadFor(int tx, int ty, int clampRow) const
|
||||
{
|
||||
const int stride = MAP_W + 1;
|
||||
const float c00 = corner[ty * stride + tx]; // верх ромба, спрайт (16,0)
|
||||
const float c10 = corner[ty * stride + tx + 1]; // право, спрайт (32,8)
|
||||
const float c01 = corner[(ty + 1) * stride + tx]; // лево, спрайт (0,8)
|
||||
const float c11 = corner[(ty + 1) * stride + tx + 1]; // низ, спрайт (16,16)
|
||||
|
||||
const float Bu = c10 - c00;
|
||||
const float Bv = c01 - c00;
|
||||
const float K = c00 - c10 - c01 + c11;
|
||||
|
||||
ShadeQuad q;
|
||||
q.a = c00 - (Bu - Bv) * 0.5f - K * 0.25f;
|
||||
q.b = (Bu - Bv + K) / float(TILE_W);
|
||||
q.c = (Bu + Bv) / float(TILE_H);
|
||||
q.e = -K / float(TILE_W * TILE_W);
|
||||
q.f = K / float(TILE_H * TILE_H);
|
||||
q.clampRow = clampRow;
|
||||
return q;
|
||||
}
|
||||
81
src/render/lighting.h
Normal file
81
src/render/lighting.h
Normal file
@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Свет и туман войны. Render-only: симуляция об этом модуле не знает вообще,
|
||||
// ИИ и стрельба работают как работали (спек 6 не тронут).
|
||||
//
|
||||
// Почему свет НЕ делается пост-проходом по пикселям:
|
||||
// спрайт стены имеет привязку ay = TILE_HALF_H + TILE_H = 24, то есть верхняя
|
||||
// грань поднята на 16 px над ромбом своего тайла. Экранный пиксель крыши,
|
||||
// прогнанный обратно через iso::ScreenToWorld, даёт ScreenToWorld(0,-16) =
|
||||
// ровно (-1,-1) тайла, то есть СОСЕДНИЙ тайл на северо-запад. Пост-проход
|
||||
// осветил бы крыши чужой видимостью: чёрные крыши на освещённых стенах.
|
||||
// Поэтому свет вплавляется в блит спрайта: спрайт целиком светится от СВОЕГО
|
||||
// тайла, и артефакт исчезает конструктивно.
|
||||
//
|
||||
// Три состояния области:
|
||||
// никогда не видели -> ступень 0 рампы (чистый чёрный, тайл не рисуется);
|
||||
// видели раньше -> тускло и холодно, сущности не показываются;
|
||||
// видно сейчас -> нормальная яркость;
|
||||
// вспышка выстрела -> верх рампы, тёплый пересвет.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "core/math.h"
|
||||
#include "render/framebuffer.h"
|
||||
#include "sim/agent.h"
|
||||
#include "sim/events.h"
|
||||
#include "tuning.h"
|
||||
#include "world/tilemap.h"
|
||||
|
||||
// Свет протекает вглубь блока стен: разделитель y=13..15 толщиной 3 тайла иначе
|
||||
// остаётся чёрным изнутри, и его южная грань висит в пустоте.
|
||||
constexpr int WALL_LIGHT_PASSES = 3;
|
||||
constexpr int LIGHT_FLASH_MAX = 24;
|
||||
|
||||
struct LightFlash
|
||||
{
|
||||
Vec2 pos{};
|
||||
float power = 0.0f;
|
||||
float life = 0.0f;
|
||||
float radius = 4.0f; // свой у каждого ствола: пистолет светит мельче
|
||||
};
|
||||
|
||||
struct Lighting
|
||||
{
|
||||
float vis[MAP_W * MAP_H] = {}; // сглаженная текущая видимость
|
||||
float mem[MAP_W * MAP_H] = {}; // память карты
|
||||
float dyn[MAP_W * MAP_H] = {}; // динамический свет (вспышки)
|
||||
float tile[MAP_W * MAP_H] = {}; // итоговый уровень на тайл
|
||||
float corner[(MAP_W + 1) * (MAP_H + 1)] = {}; // узловая сетка для билинейки
|
||||
|
||||
LightFlash flashes[LIGHT_FLASH_MAX];
|
||||
int flashCount = 0;
|
||||
uint32_t eventCursor = 0;
|
||||
|
||||
bool enabled = true; // F5 — аварийно выключить туман целиком
|
||||
|
||||
void Reset();
|
||||
|
||||
// Забрать вспышки из журнала боя (то, что накопилось за кадр).
|
||||
// agents нужны, чтобы взять параметры вспышки с оружия стрелявшего:
|
||||
// у пистолета она мельче и холоднее, чем у карабина.
|
||||
void Ingest(const CombatEventLog& log, const Agent* agents, int agentCount);
|
||||
|
||||
// Пересчитать поле света. agents/count — только чтение.
|
||||
void Update(const Agent* agents, int count, const Tilemap& map, float dt);
|
||||
|
||||
// Уровень света в мировой точке (для сущностей: они светятся от своей
|
||||
// позиции, а не от того, что под ними на экране).
|
||||
float LightAt(Vec2 world) const;
|
||||
|
||||
// Патч света для тайла: точная билинейка по четырём углам его ромба.
|
||||
ShadeQuad QuadFor(int tx, int ty, int clampRow) const;
|
||||
|
||||
// Максимум по четырём углам: если он ниже ступени 1 рампы, тайл рисовать
|
||||
// не нужно — результат пиксель-в-пиксель совпадёт с цветом очистки.
|
||||
float MaxCornerOf(int tx, int ty) const;
|
||||
|
||||
float TileLevel(int tx, int ty) const
|
||||
{
|
||||
if (!Tilemap::InBounds(tx, ty)) return 0.0f;
|
||||
return tile[ty * MAP_W + tx];
|
||||
}
|
||||
};
|
||||
13
src/render/noise.h
Normal file
13
src/render/noise.h
Normal file
@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Псевдослучайность рендера.
|
||||
//
|
||||
// Сами функции живут в core/hash.h: они понадобились и симуляции (разброс
|
||||
// выстрела считается детерминированным хешем от номера выстрела, а не
|
||||
// генератором). Здесь остался только вход для рендера, чтобы не расползались
|
||||
// включения: зерно, дрожание, пепел и вспышки берут «случайность» отсюда.
|
||||
//
|
||||
// Состояния нет ни у кого: значение зависит только от аргументов (координата,
|
||||
// номер кадра), общего генератора нет и синхронизировать нечего.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "core/hash.h"
|
||||
279
src/render/post.cpp
Normal file
279
src/render/post.cpp
Normal file
@ -0,0 +1,279 @@
|
||||
#include "render/post.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "render/noise.h"
|
||||
#include "tuning.h"
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Dread
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
void Dread::Reset()
|
||||
{
|
||||
level = 0.0f;
|
||||
heartPhase = 0.0f;
|
||||
shake = 0.0f;
|
||||
pulse = 0.0f;
|
||||
}
|
||||
|
||||
void Dread::Update(const DreadInput& in, float frameDt)
|
||||
{
|
||||
// Чего боимся: близкая мишень, близкая НЕВИДИМАЯ мишень (страшнее всего),
|
||||
// толпа, и отсутствие огневых линий — отряд слеп и беспомощен.
|
||||
const float r = std::max(0.5f, g_tune.dreadRadius);
|
||||
const float near_ = Clampf(1.0f - in.nearestTargetDist / r, 0.0f, 1.0f);
|
||||
const float unseen = Clampf(1.0f - in.nearestUnseenDist / r, 0.0f, 1.0f);
|
||||
const float crowd = Clampf(float(in.targetsNear) / 4.0f, 0.0f, 1.0f);
|
||||
const float blind = 1.0f - Clampf(float(in.agentsWithLane) / float(NUM_AGENTS), 0.0f, 1.0f);
|
||||
|
||||
float target = Clampf(0.45f * near_ + 0.75f * unseen + 0.25f * crowd + 0.30f * blind,
|
||||
0.0f, 1.0f);
|
||||
if (in.friendlyDelta > 0) target = 1.0f; // попали по своему — максимум
|
||||
|
||||
// Асимметрия: страх набегает быстро, отпускает медленно.
|
||||
const float rate = (target > level) ? g_tune.dreadAttack : g_tune.dreadRelease;
|
||||
level += (target - level) * (1.0f - std::exp(-rate * frameDt));
|
||||
level = Clampf(level, 0.0f, 1.0f);
|
||||
|
||||
// Сердцебиение «туп-туп»: частота растёт с напряжением.
|
||||
const float bpm = Lerpf(g_tune.heartBpmCalm, g_tune.heartBpmTense, level);
|
||||
heartPhase += frameDt * (bpm / 60.0f);
|
||||
if (heartPhase >= 1.0f) heartPhase -= std::floor(heartPhase);
|
||||
|
||||
// Отдача от выстрелов и вспышка тона.
|
||||
if (in.shotsDelta > 0)
|
||||
{
|
||||
// Отдача пришла посчитанной по стволам (render/fx.cpp): пистолет трясёт
|
||||
// кадр слабее карабина. Если её почему-то нет — общий множитель.
|
||||
const float kick = (in.shotShake > 0.0f) ? in.shotShake
|
||||
: g_tune.shakePerShot * float(in.shotsDelta);
|
||||
shake = std::min(g_tune.shakeMax, shake + kick);
|
||||
pulse = std::min(1.0f, pulse + 0.25f * float(in.shotsDelta));
|
||||
}
|
||||
if (in.killsDelta > 0) shake = std::min(g_tune.shakeMax, shake + g_tune.shakePerShot * 2.0f);
|
||||
|
||||
// Удар клинком трясёт кадр сильнее выстрела: он всегда в упор, и вес
|
||||
// попадания — единственное, чем ближний бой отличается от размахивания.
|
||||
if (in.meleeHitsDelta > 0)
|
||||
{
|
||||
shake = std::min(g_tune.shakeMax, shake + g_tune.meleeShake * float(in.meleeHitsDelta));
|
||||
pulse = std::min(1.0f, pulse + 0.35f * float(in.meleeHitsDelta));
|
||||
}
|
||||
|
||||
shake *= std::exp(-g_tune.shakeDecay * frameDt);
|
||||
pulse *= std::exp(-6.0f * frameDt);
|
||||
}
|
||||
|
||||
void Dread::CameraOffset(uint32_t frame, int& dx, int& dy) const
|
||||
{
|
||||
// Отдача — детерминированный хеш от номера кадра, без общего генератора.
|
||||
const float amp = shake;
|
||||
if (amp < 0.05f) { dx = 0; dy = 0; return; }
|
||||
dx = int(std::lround(HashSigned(Hash2(frame, 11u)) * amp));
|
||||
dy = int(std::lround(HashSigned(Hash2(frame, 29u)) * amp));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostFx
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Гладкая ступенька для интерполяции шума.
|
||||
inline float SmoothStep01(float t) { return t * t * (3.0f - 2.0f * t); }
|
||||
|
||||
// Значение шума в точке решётки с билинейной интерполяцией.
|
||||
float LatticeValue(float x, float y)
|
||||
{
|
||||
const float fx = std::floor(x);
|
||||
const float fy = std::floor(y);
|
||||
const int xi = int(fx);
|
||||
const int yi = int(fy);
|
||||
const float tx = SmoothStep01(x - fx);
|
||||
const float ty = SmoothStep01(y - fy);
|
||||
|
||||
const float a = Hash01(Hash2(uint32_t(xi + 8192), uint32_t(yi + 8192)));
|
||||
const float b = Hash01(Hash2(uint32_t(xi + 8193), uint32_t(yi + 8192)));
|
||||
const float c = Hash01(Hash2(uint32_t(xi + 8192), uint32_t(yi + 8193)));
|
||||
const float d = Hash01(Hash2(uint32_t(xi + 8193), uint32_t(yi + 8193)));
|
||||
|
||||
return Lerpf(Lerpf(a, b, tx), Lerpf(c, d, tx), ty);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void PostFx::Init(int w, int h)
|
||||
{
|
||||
vigD.assign(size_t(w) * size_t(h), 0);
|
||||
|
||||
hazeW = w / HAZE_CELL + 2;
|
||||
hazeH = h / HAZE_CELL + 2;
|
||||
hazeGrid.assign(size_t(hazeW) * size_t(hazeH), 0);
|
||||
|
||||
const float cx = float(w) * 0.5f;
|
||||
const float cy = float(h) * 0.5f;
|
||||
const float norm = 1.0f / std::sqrt(cx * cx + cy * cy);
|
||||
|
||||
for (int y = 0; y < h; ++y)
|
||||
{
|
||||
for (int x = 0; x < w; ++x)
|
||||
{
|
||||
const float dx = (float(x) - cx);
|
||||
const float dy = (float(y) - cy) * 1.15f; // экран шире, чем выше
|
||||
const float d = std::sqrt(dx * dx + dy * dy) * norm; // 0 в центре, ~1 в углу
|
||||
vigD[size_t(y) * w + x] = uint8_t(Clampf(d, 0.0f, 1.0f) * 255.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Два октава шума по узлам сетки. Клубы сносит вбок и медленно перемешивает —
|
||||
// это и есть «пыль в воздухе»: не текстура и не частицы, а неоднородность
|
||||
// объёма, которую видно только там, где есть чему подсвечиваться.
|
||||
void PostFx::BuildHaze(float time)
|
||||
{
|
||||
const float scale = 1.0f / std::max(0.05f, g_tune.hazeScale);
|
||||
const float drift = time * g_tune.hazeDrift;
|
||||
|
||||
for (int gy = 0; gy < hazeH; ++gy)
|
||||
{
|
||||
for (int gx = 0; gx < hazeW; ++gx)
|
||||
{
|
||||
const float x = float(gx) * 0.34f * scale;
|
||||
const float y = float(gy) * 0.34f * scale;
|
||||
// Второй октав идёт против сноса — клубы не «едут доской».
|
||||
const float v = 0.62f * LatticeValue(x + drift, y + drift * 0.35f) +
|
||||
0.38f * LatticeValue(x * 2.7f - drift * 1.6f, y * 2.7f + drift * 0.8f);
|
||||
hazeGrid[size_t(gy) * hazeW + gx] = uint8_t(Clampf(v, 0.0f, 1.0f) * 255.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void PostFx::Apply(Framebuffer& fb, const Dread& dread, float time)
|
||||
{
|
||||
++frame;
|
||||
if (!enabled) return;
|
||||
if (vigD.size() != fb.px.size()) return;
|
||||
|
||||
const bool hazeOn = (g_tune.hazeAmount > 0.001f) &&
|
||||
(hazeGrid.size() == size_t(hazeW) * size_t(hazeH)) && hazeW > 0;
|
||||
if (hazeOn) BuildHaze(time);
|
||||
// Сколько уровней яркости добавляет дымка на пике.
|
||||
const int hazeAmp = int(Clampf(g_tune.hazeAmount, 0.0f, 2.0f) * 44.0f);
|
||||
const int hazeGate = std::max(1, int(g_tune.hazeGate));
|
||||
|
||||
// --- таблицы кадра -------------------------------------------------------
|
||||
|
||||
// Виньетка: в напряжении темнее и «сужается».
|
||||
const float vigStart = Lerpf(g_tune.vigStartCalm, g_tune.vigStartTense, dread.level);
|
||||
const float vigDepth = Lerpf(g_tune.vigDepthCalm, g_tune.vigDepthTense, dread.level);
|
||||
|
||||
// Сердцебиение: два коротких удара за цикл, слышно только в напряжении.
|
||||
const float hp = dread.heartPhase;
|
||||
float beat = 0.0f;
|
||||
if (hp < 0.10f) beat = 1.0f - hp / 0.10f;
|
||||
else if (hp < 0.22f) beat = 0.55f * (1.0f - (hp - 0.10f) / 0.12f);
|
||||
const float beatAmp = beat * dread.level * g_tune.heartAmp;
|
||||
|
||||
uint32_t vigLut[256];
|
||||
for (int i = 0; i < 256; ++i)
|
||||
{
|
||||
const float d = float(i) / 255.0f;
|
||||
float k = 1.0f - vigDepth * Clampf((d - vigStart) / std::max(0.001f, 1.0f - vigStart),
|
||||
0.0f, 1.0f);
|
||||
k *= (1.0f - beatAmp);
|
||||
k *= (1.0f + dread.pulse * g_tune.shotFlash);
|
||||
vigLut[i] = uint32_t(Clampf(k, 0.0f, 3.0f) * 256.0f);
|
||||
}
|
||||
|
||||
// Зерно сильнее в тенях: в свету плёнка чистая, в темноте шумит.
|
||||
const float grainBase = Lerpf(g_tune.grainCalm, g_tune.grainTense, dread.level);
|
||||
int grainAmp[256];
|
||||
for (int i = 0; i < 256; ++i)
|
||||
{
|
||||
// Не выше 127: индекс sat[] равен 128 + канал + шум и обязан остаться
|
||||
// внутри 512, иначе в Release пойдут случайные яркие точки.
|
||||
const int v = int(grainBase * (1.0f - float(i) / 320.0f));
|
||||
grainAmp[i] = v < 0 ? 0 : (v > 127 ? 127 : v);
|
||||
}
|
||||
|
||||
// Насыщение: индекс 128 + значение + шум, диапазон [1, 510] при шуме +-127.
|
||||
static uint8_t sat[512];
|
||||
static bool satReady = false;
|
||||
if (!satReady)
|
||||
{
|
||||
for (int i = 0; i < 512; ++i)
|
||||
{
|
||||
const int v = i - 128;
|
||||
sat[i] = uint8_t(v < 0 ? 0 : (v > 255 ? 255 : v));
|
||||
}
|
||||
satReady = true;
|
||||
}
|
||||
|
||||
// --- один слитый проход по пикселям --------------------------------------
|
||||
const int w = fb.w;
|
||||
const int h = fb.h;
|
||||
uint32_t* p = fb.px.data();
|
||||
const uint8_t* vg = vigD.data();
|
||||
|
||||
for (int y = 0; y < h; ++y)
|
||||
{
|
||||
for (int x = 0; x < w; ++x)
|
||||
{
|
||||
const size_t i = size_t(y) * w + x;
|
||||
uint32_t c = p[i];
|
||||
|
||||
const uint32_t r = c & 0xFFu;
|
||||
const uint32_t g = (c >> 8) & 0xFFu;
|
||||
const uint32_t b = (c >> 16) & 0xFFu;
|
||||
|
||||
const uint32_t k = vigLut[vg[i]];
|
||||
uint32_t rr = (r * k) >> 8;
|
||||
uint32_t gg = (g * k) >> 8;
|
||||
uint32_t bb = (b * k) >> 8;
|
||||
if (rr > 255u) rr = 255u;
|
||||
if (gg > 255u) gg = 255u;
|
||||
if (bb > 255u) bb = 255u;
|
||||
|
||||
uint32_t luma = (rr * 77u + gg * 151u + bb * 28u) >> 8;
|
||||
|
||||
// --- дымка ---------------------------------------------------------
|
||||
// Пыль подсвечивается тем, что вокруг. В неразведанной черноте её
|
||||
// быть не может: иначе туман войны превратился бы в серую простыню.
|
||||
if (hazeOn && luma > 0u)
|
||||
{
|
||||
const int gx = x >> 3;
|
||||
const int gy = y >> 3;
|
||||
const int fx = (x & 7) * 32;
|
||||
const int fy = (y & 7) * 32;
|
||||
const uint8_t* row0 = &hazeGrid[size_t(gy) * hazeW + gx];
|
||||
const uint8_t* row1 = row0 + hazeW;
|
||||
const int top = int(row0[0]) * (256 - fx) + int(row0[1]) * fx;
|
||||
const int bot = int(row1[0]) * (256 - fx) + int(row1[1]) * fx;
|
||||
const int v = (top * (256 - fy) + bot * fy) >> 16; // 0..255
|
||||
|
||||
int add = (hazeAmp * v) >> 8;
|
||||
if (int(luma) < hazeGate) add = add * int(luma) / hazeGate;
|
||||
if (add > 0)
|
||||
{
|
||||
rr = std::min(255u, rr + uint32_t(add * 7 / 8));
|
||||
gg = std::min(255u, gg + uint32_t(add * 15 / 16));
|
||||
bb = std::min(255u, bb + uint32_t(add));
|
||||
luma = (rr * 77u + gg * 151u + bb * 28u) >> 8;
|
||||
}
|
||||
}
|
||||
|
||||
const int amp = grainAmp[luma & 0xFFu];
|
||||
if (amp > 0)
|
||||
{
|
||||
const int n = int(HashSigned(Hash3(uint32_t(x), uint32_t(y), frame)) * float(amp));
|
||||
rr = sat[128 + int(rr) + n];
|
||||
gg = sat[128 + int(gg) + n];
|
||||
bb = sat[128 + int(bb) + n];
|
||||
}
|
||||
|
||||
p[i] = 0xFF000000u | rr | (gg << 8) | (bb << 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/render/post.h
Normal file
67
src/render/post.h
Normal file
@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Настроение кадра: напряжение (Dread) и один слитый пост-проход по пикселям.
|
||||
//
|
||||
// Пост-проход НЕ занимается светом — свет уже вплавлен на блите спрайтов
|
||||
// (см. render/lighting.h, почему иначе нельзя). Здесь только виньетка, зерно
|
||||
// и тон, то есть то, что честно считается в экранном пространстве.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/math.h"
|
||||
#include "render/framebuffer.h"
|
||||
|
||||
// Что кадр знает о происходящем — собирается из const Game& в RenderGame.
|
||||
struct DreadInput
|
||||
{
|
||||
float nearestTargetDist = 99.0f; // до ближайшей живой мишени
|
||||
float nearestUnseenDist = 99.0f; // до ближайшей мишени, которую НЕ видно
|
||||
int targetsNear = 0; // сколько мишеней рядом
|
||||
int agentsWithLane = 0; // сколько агентов при линии огня
|
||||
int shotsDelta = 0; // выстрелов за кадр (дельта, не флаг)
|
||||
float shotShake = 0.0f; // суммарная отдача от них: у каждого ствола своя
|
||||
int killsDelta = 0;
|
||||
int friendlyDelta = 0;
|
||||
int meleeHitsDelta = 0; // ударов клинком за кадр
|
||||
};
|
||||
|
||||
// Напряжение: медленно отпускает, быстро нарастает. Кормит виньетку, зерно,
|
||||
// сердцебиение и тряску камеры.
|
||||
struct Dread
|
||||
{
|
||||
float level = 0.0f; // 0..1
|
||||
float heartPhase = 0.0f;
|
||||
float shake = 0.0f; // текущая амплитуда отдачи, в пикселях
|
||||
float pulse = 0.0f; // короткий выброс тона на выстрел
|
||||
|
||||
void Reset();
|
||||
void Update(const DreadInput& in, float frameDt);
|
||||
|
||||
// Смещение камеры от отдачи и дыхания, в целых пикселях.
|
||||
void CameraOffset(uint32_t frame, int& dx, int& dy) const;
|
||||
};
|
||||
|
||||
// Ячейка сетки дымки в пикселях кадра. 8 — компромисс: клубы получаются
|
||||
// достаточно крупными, чтобы читаться как объём, а сетка остаётся крошечной
|
||||
// (62x35 узлов), и её пересчёт каждый кадр ничего не стоит.
|
||||
constexpr int HAZE_CELL = 8;
|
||||
|
||||
struct PostFx
|
||||
{
|
||||
std::vector<uint8_t> vigD; // статичное поле виньетки, строится один раз
|
||||
// Дымка: поле шума по узлам сетки, между узлами — билинейка на лету.
|
||||
// Полноразмерное поле считать незачем: клубы всё равно низкочастотные.
|
||||
std::vector<uint8_t> hazeGrid;
|
||||
int hazeW = 0;
|
||||
int hazeH = 0;
|
||||
|
||||
uint32_t frame = 0;
|
||||
bool enabled = true;
|
||||
|
||||
void Init(int w, int h);
|
||||
void Apply(Framebuffer& fb, const Dread& dread, float time);
|
||||
|
||||
private:
|
||||
void BuildHaze(float time);
|
||||
};
|
||||
101
src/render/scene.cpp
Normal file
101
src/render/scene.cpp
Normal file
@ -0,0 +1,101 @@
|
||||
#include "render/scene.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Запас за краями кадра, чтобы не отсекать спрайты, торчащие внутрь.
|
||||
constexpr int CULL_MARGIN_X = TILE_W;
|
||||
constexpr int CULL_MARGIN_Y = WALL_H + TILE_H;
|
||||
|
||||
inline bool OffScreen(int sx, int sy)
|
||||
{
|
||||
return sx < -CULL_MARGIN_X || sx > INTERNAL_W + CULL_MARGIN_X ||
|
||||
sy < -CULL_MARGIN_Y || sy > INTERNAL_H + CULL_MARGIN_Y;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Scene::Begin(const iso::Camera& cam)
|
||||
{
|
||||
items.clear();
|
||||
cam.ScreenOffset(ox, oy);
|
||||
}
|
||||
|
||||
void Scene::Add(const Sprite* spr, Vec2 world, int pixelLift, float depthBias)
|
||||
{
|
||||
const Vec2 s = iso::WorldToScreen(world);
|
||||
const int sx = int(std::lround(s.x)) + ox;
|
||||
const int sy = int(std::lround(s.y)) + oy - pixelLift;
|
||||
if (OffScreen(sx, sy)) return;
|
||||
|
||||
iso::DrawItem it;
|
||||
it.depth = iso::Depth(world) + depthBias;
|
||||
it.spr = spr;
|
||||
it.x = sx;
|
||||
it.y = sy;
|
||||
items.push_back(it);
|
||||
}
|
||||
|
||||
void Scene::AddShaded(const Sprite* spr, Vec2 world, const ShadeQuad& quad, int pixelLift,
|
||||
float depthBias)
|
||||
{
|
||||
const Vec2 s = iso::WorldToScreen(world);
|
||||
const int sx = int(std::lround(s.x)) + ox;
|
||||
const int sy = int(std::lround(s.y)) + oy - pixelLift;
|
||||
if (OffScreen(sx, sy)) return;
|
||||
|
||||
iso::DrawItem it;
|
||||
it.depth = iso::Depth(world) + depthBias;
|
||||
it.spr = spr;
|
||||
it.x = sx;
|
||||
it.y = sy;
|
||||
it.shaded = true;
|
||||
it.quad = quad;
|
||||
items.push_back(it);
|
||||
}
|
||||
|
||||
void Scene::Flush(Framebuffer& fb)
|
||||
{
|
||||
iso::SortByDepth(items);
|
||||
iso::FlushDrawList(fb, items);
|
||||
}
|
||||
|
||||
// Ниже этого уровня рампа даёт ступень 0, то есть чистый чёрный.
|
||||
static const float DARK_CUTOFF = 0.5f / float(RAMP_LEVELS - 1);
|
||||
|
||||
void DrawFloor(Framebuffer& fb, const SpriteSet& sprites, const Tilemap& map, int ox, int oy,
|
||||
const Lighting& light, const BloodField* blood)
|
||||
{
|
||||
for (int ty = 0; ty < MAP_H; ++ty)
|
||||
{
|
||||
for (int tx = 0; tx < MAP_W; ++tx)
|
||||
{
|
||||
if (map.IsWall(tx, ty)) continue; // под стеной пол не виден
|
||||
if (light.MaxCornerOf(tx, ty) < DARK_CUTOFF) continue;
|
||||
|
||||
const Vec2 c = iso::WorldToScreen({tx + 0.5f, ty + 0.5f});
|
||||
const int sx = int(std::lround(c.x)) + ox;
|
||||
const int sy = int(std::lround(c.y)) + oy;
|
||||
if (OffScreen(sx, sy)) continue;
|
||||
|
||||
const Sprite* stained = blood ? blood->At(tx, ty) : nullptr;
|
||||
const Sprite& s = stained ? *stained : sprites.FloorTile(tx, ty);
|
||||
fb.BlitShaded(s, sx, sy, light.QuadFor(tx, ty, TILE_H - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CollectWalls(Scene& scene, const SpriteSet& sprites, const Tilemap& map,
|
||||
const Lighting& light)
|
||||
{
|
||||
for (int ty = 0; ty < MAP_H; ++ty)
|
||||
for (int tx = 0; tx < MAP_W; ++tx)
|
||||
{
|
||||
if (!map.IsWall(tx, ty)) continue;
|
||||
if (light.MaxCornerOf(tx, ty) < DARK_CUTOFF) continue;
|
||||
// clampRow = TILE_H-1: вертикальные грани светятся как нижняя кромка
|
||||
// крыши, а не как экстраполяция патча на 32 строки вниз.
|
||||
scene.AddShaded(&sprites.wall, {tx + 0.5f, ty + 0.5f},
|
||||
light.QuadFor(tx, ty, TILE_H - 1));
|
||||
}
|
||||
}
|
||||
46
src/render/scene.h
Normal file
46
src/render/scene.h
Normal file
@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Сборка кадра: сплошной проход по полу, затем общий список стен и сущностей,
|
||||
// отсортированный по глубине (wx + wy).
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <vector>
|
||||
|
||||
#include "render/blood.h"
|
||||
#include "render/framebuffer.h"
|
||||
#include "render/iso.h"
|
||||
#include "render/sprites.h"
|
||||
#include "render/lighting.h"
|
||||
#include "world/tilemap.h"
|
||||
|
||||
struct Scene
|
||||
{
|
||||
std::vector<iso::DrawItem> items;
|
||||
int ox = 0; // смещение камеры в пикселях (целое)
|
||||
int oy = 0;
|
||||
|
||||
void Begin(const iso::Camera& cam);
|
||||
|
||||
// world — мировая позиция «подошвы» сущности;
|
||||
// pixelLift — подъём спрайта по экрану (пули летят не по земле);
|
||||
// depthBias — ручная правка ключа сортировки.
|
||||
void Add(const Sprite* spr, Vec2 world, int pixelLift = 0, float depthBias = 0.0f);
|
||||
// Тот же элемент, но с патчем света.
|
||||
void AddShaded(const Sprite* spr, Vec2 world, const ShadeQuad& quad, int pixelLift = 0,
|
||||
float depthBias = 0.0f);
|
||||
|
||||
void Flush(Framebuffer& fb);
|
||||
};
|
||||
|
||||
// Пол — первым сплошным проходом, в список сортировки не попадает.
|
||||
// Полностью тёмные тайлы пропускаются: ступень 0 рампы совпадает с цветом
|
||||
// очистки, поэтому результат пиксель-в-пиксель тот же, а рисовать дешевле.
|
||||
//
|
||||
// blood == nullptr — крови нет. Иначе у запачканных тайлов берётся запечённая
|
||||
// копия ромба. Стоимость кадра от этого не меняется: тот же один блит на тайл,
|
||||
// сколько бы крови ни накопилось (render/blood.h).
|
||||
void DrawFloor(Framebuffer& fb, const SpriteSet& sprites, const Tilemap& map, int ox, int oy,
|
||||
const Lighting& light, const BloodField* blood);
|
||||
|
||||
// Все WALL-тайлы уходят в общий список вместе с сущностями.
|
||||
void CollectWalls(Scene& scene, const SpriteSet& sprites, const Tilemap& map,
|
||||
const Lighting& light);
|
||||
202
src/render/sprites.cpp
Normal file
202
src/render/sprites.cpp
Normal file
@ -0,0 +1,202 @@
|
||||
#include "render/sprites.h"
|
||||
|
||||
#include "core/math.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Занятая часть строки y для ромба 32x16.
|
||||
void DiamondRow(int y, int& x0, int& x1)
|
||||
{
|
||||
const int k = (y < TILE_HALF_H) ? y : (TILE_H - 1 - y);
|
||||
const int width = 4 * (k + 1);
|
||||
x0 = TILE_HALF_W - width / 2;
|
||||
x1 = x0 + width;
|
||||
}
|
||||
|
||||
// Обводка по силуэту: любой непрозрачный пиксель, у которого есть прозрачный
|
||||
// 4-сосед (или край спрайта), перекрашивается в outline.
|
||||
void OutlineSilhouette(Sprite& s, uint32_t outline)
|
||||
{
|
||||
Sprite copy = s;
|
||||
for (int y = 0; y < s.h; ++y)
|
||||
{
|
||||
for (int x = 0; x < s.w; ++x)
|
||||
{
|
||||
if (AlphaOf(copy.Get(x, y)) == 0) continue;
|
||||
const bool border =
|
||||
x == 0 || y == 0 || x == s.w - 1 || y == s.h - 1 ||
|
||||
AlphaOf(copy.Get(x - 1, y)) == 0 || AlphaOf(copy.Get(x + 1, y)) == 0 ||
|
||||
AlphaOf(copy.Get(x, y - 1)) == 0 || AlphaOf(copy.Get(x, y + 1)) == 0;
|
||||
if (border) s.Set(x, y, outline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Sprite MakeFloor(uint32_t base)
|
||||
{
|
||||
Sprite s;
|
||||
s.Init(TILE_W, TILE_H, 0);
|
||||
s.ax = TILE_HALF_W;
|
||||
s.ay = TILE_HALF_H;
|
||||
|
||||
const uint32_t edge = ShadeColor(base, 0.78f);
|
||||
const uint32_t fleck = ShadeColor(base, 1.18f);
|
||||
|
||||
for (int y = 0; y < TILE_H; ++y)
|
||||
{
|
||||
int x0 = 0, x1 = 0;
|
||||
DiamondRow(y, x0, x1);
|
||||
for (int x = x0; x < x1; ++x)
|
||||
{
|
||||
uint32_t c = base;
|
||||
if (x < x0 + 2 || x >= x1 - 2) c = edge; // рант тайла
|
||||
else if (((x * 7 + y * 13) % 23) == 0) c = fleck; // «металлическая» рябь
|
||||
s.Set(x, y, c);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
Sprite MakeWall(uint32_t top)
|
||||
{
|
||||
Sprite s;
|
||||
s.Init(TILE_W, WALL_H, 0);
|
||||
s.ax = TILE_HALF_W;
|
||||
s.ay = TILE_HALF_H + TILE_H; // низ блока садится на ромб пола
|
||||
|
||||
const uint32_t left = ShadeColor(top, 0.62f);
|
||||
const uint32_t right = ShadeColor(top, 0.80f);
|
||||
|
||||
// верхняя грань
|
||||
for (int y = 0; y < TILE_H; ++y)
|
||||
{
|
||||
int x0 = 0, x1 = 0;
|
||||
DiamondRow(y, x0, x1);
|
||||
for (int x = x0; x < x1; ++x) s.Set(x, y, top);
|
||||
}
|
||||
|
||||
// вертикальные грани: от нижней кромки ромба вниз на высоту тайла
|
||||
for (int x = 0; x < TILE_W; ++x)
|
||||
{
|
||||
int bottom = -1;
|
||||
for (int y = 0; y < TILE_H; ++y)
|
||||
if (AlphaOf(s.Get(x, y)) != 0) bottom = y;
|
||||
if (bottom < 0) continue;
|
||||
|
||||
const uint32_t side = (x < TILE_HALF_W) ? left : right;
|
||||
for (int y = bottom + 1; y <= bottom + TILE_H && y < WALL_H; ++y)
|
||||
s.Set(x, y, side);
|
||||
}
|
||||
|
||||
OutlineSilhouette(s, ShadeColor(top, 0.35f));
|
||||
return s;
|
||||
}
|
||||
|
||||
// Капсула с обводкой и бликом.
|
||||
Sprite MakeCapsule(int w, int h, float radius, uint32_t body)
|
||||
{
|
||||
Sprite s;
|
||||
s.Init(w, h, 0);
|
||||
s.ax = w / 2;
|
||||
s.ay = h - 2; // «ноги» чуть выше нижнего края
|
||||
|
||||
const uint32_t outline = ShadeColor(body, 0.35f);
|
||||
const uint32_t hilite = ShadeColor(body, 1.35f);
|
||||
const uint32_t shadow = ShadeColor(body, 0.72f);
|
||||
|
||||
const float cx = (w - 1) * 0.5f;
|
||||
const Vec2 a{cx, radius - 0.5f};
|
||||
const Vec2 b{cx, h - radius - 0.5f};
|
||||
|
||||
for (int y = 0; y < h; ++y)
|
||||
{
|
||||
for (int x = 0; x < w; ++x)
|
||||
{
|
||||
const float d = DistancePointToSegment({float(x), float(y)}, a, b);
|
||||
if (d > radius) continue;
|
||||
if (d > radius - 1.0f) { s.Set(x, y, outline); continue; }
|
||||
|
||||
uint32_t c = body;
|
||||
if (float(x) < cx - 0.5f && y < h / 2) c = hilite;
|
||||
else if (float(x) > cx + 1.0f) c = shadow;
|
||||
s.Set(x, y, c);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Боец ближнего боя: та же капсула, но шире в плечах и с клинком у бедра.
|
||||
// На 480x270 класс обязан читаться силуэтом — цвет уже занят номером агента.
|
||||
Sprite MakeMeleeCapsule(int w, int h, float radius, uint32_t body)
|
||||
{
|
||||
Sprite s = MakeCapsule(w, h, radius, body);
|
||||
|
||||
const uint32_t steel = RGBA(196, 206, 222);
|
||||
const uint32_t edge = RGBA(240, 246, 255);
|
||||
|
||||
// Клинок: короткая вертикаль вдоль правого края силуэта, с бликом.
|
||||
const int bx = w - 2;
|
||||
for (int y = 2; y < h - 4; ++y)
|
||||
{
|
||||
s.Set(bx, y, steel);
|
||||
if (y < h / 2) s.Set(bx, y, edge);
|
||||
}
|
||||
// Гарда, чтобы клинок не читался как случайная полоска.
|
||||
s.Set(bx - 1, h - 5, steel);
|
||||
s.Set(bx, h - 5, steel);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Стрелок с пистолетом: та же капсула, но уже в плечах и с коротким стволом
|
||||
// у пояса. Класс оружия обязан читаться силуэтом — цвет уже занят номером.
|
||||
Sprite MakePistolCapsule(int w, int h, float radius, uint32_t body)
|
||||
{
|
||||
Sprite s = MakeCapsule(w, h, radius, body);
|
||||
|
||||
const uint32_t steel = RGBA(150, 156, 168);
|
||||
const uint32_t dark = RGBA(70, 74, 84);
|
||||
|
||||
// Коротышка-ствол: две пиксельных полоски у бедра, с рукоятью вниз.
|
||||
const int bx = w - 2;
|
||||
const int by = h - 7;
|
||||
s.Set(bx, by, steel);
|
||||
s.Set(bx - 1, by, steel);
|
||||
s.Set(bx, by + 1, dark);
|
||||
s.Set(bx, by + 2, dark);
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
uint32_t ShadeColor(uint32_t c, float k)
|
||||
{
|
||||
const float r = Clampf(float(c & 0xFF) * k, 0.0f, 255.0f);
|
||||
const float g = Clampf(float((c >> 8) & 0xFF) * k, 0.0f, 255.0f);
|
||||
const float b = Clampf(float((c >> 16) & 0xFF) * k, 0.0f, 255.0f);
|
||||
return RGBA(uint8_t(r), uint8_t(g), uint8_t(b), AlphaOf(c));
|
||||
}
|
||||
|
||||
void SpriteSet::Generate()
|
||||
{
|
||||
floorA = MakeFloor(RGBA(50, 54, 62));
|
||||
floorB = MakeFloor(RGBA(42, 46, 53));
|
||||
wall = MakeWall(RGBA(78, 84, 96));
|
||||
|
||||
// 5 различимых цветов — по одному на агента
|
||||
static const uint32_t agentCols[NUM_AGENTS] = {
|
||||
RGBA(80, 200, 220),
|
||||
RGBA(232, 176, 72),
|
||||
RGBA(116, 204, 116),
|
||||
RGBA(212, 116, 200),
|
||||
RGBA(116, 148, 240),
|
||||
};
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
agent[i] = MakeCapsule(10, 14, 4.0f, agentCols[i]);
|
||||
melee[i] = MakeMeleeCapsule(12, 15, 4.6f, agentCols[i]);
|
||||
pistol[i] = MakePistolCapsule(9, 13, 3.6f, agentCols[i]);
|
||||
}
|
||||
|
||||
target = MakeCapsule(12, 16, 5.0f, RGBA(120, 44, 44));
|
||||
}
|
||||
31
src/render/sprites.h
Normal file
31
src/render/sprites.h
Normal file
@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Процедурная генерация всех спрайтов при старте. Файлов с ассетами нет.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "render/framebuffer.h"
|
||||
#include "tuning.h"
|
||||
|
||||
struct SpriteSet
|
||||
{
|
||||
Sprite floorA; // шахматка пола, вариант 1
|
||||
Sprite floorB; // вариант 2
|
||||
Sprite wall; // блок 32x32
|
||||
Sprite agent[NUM_AGENTS]; // стрелок: по цвету на агента
|
||||
Sprite melee[NUM_AGENTS]; // тот же цвет, но силуэт с клинком
|
||||
Sprite target;
|
||||
// Спрайта пули нет: снаряд рисуется светящимся трассером (render/fx.cpp).
|
||||
|
||||
Sprite pistol[NUM_AGENTS]; // тот же цвет, но силуэт с коротким стволом
|
||||
|
||||
void Generate();
|
||||
|
||||
// Какой ромб пола лежит на тайле. Одно место на весь рендер: запечённая
|
||||
// кровь копирует ИМЕННО его, иначе шахматка поедет (render/blood.h).
|
||||
const Sprite& FloorTile(int tx, int ty) const
|
||||
{
|
||||
return ((tx + ty) & 1) ? floorA : floorB;
|
||||
}
|
||||
};
|
||||
|
||||
// Затемнить/осветлить цвет (множитель по RGB, альфа сохраняется).
|
||||
uint32_t ShadeColor(uint32_t c, float k);
|
||||
181
src/render/view.cpp
Normal file
181
src/render/view.cpp
Normal file
@ -0,0 +1,181 @@
|
||||
#include "render/view.h"
|
||||
|
||||
#include "game.h"
|
||||
#include "render/noise.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr uint32_t COL_BG = RGBA(0, 0, 0); // ступень 0 рампы == цвет очистки
|
||||
}
|
||||
|
||||
void View::Init()
|
||||
{
|
||||
post.Init(INTERNAL_W, INTERNAL_H);
|
||||
RefreshRamp();
|
||||
}
|
||||
|
||||
void View::RefreshRamp()
|
||||
{
|
||||
if (rampGamma == g_tune.lightGamma && rampGain == g_tune.lightMaxGain) return;
|
||||
rampGamma = g_tune.lightGamma;
|
||||
rampGain = g_tune.lightMaxGain;
|
||||
BuildShadeRamp(rampGamma, rampGain);
|
||||
}
|
||||
|
||||
void View::Reset(const Game& game)
|
||||
{
|
||||
lighting.Reset();
|
||||
fx.Reset();
|
||||
dread.Reset();
|
||||
// Кровь вечная, но не переживает смену уровня: пол под ней уже другой.
|
||||
blood.Reset();
|
||||
atmos.Reset();
|
||||
cam.SnapTo(game.squad.anchor);
|
||||
prevShots = game.metrics.shotsFired;
|
||||
prevKills = game.metrics.targetsKilled;
|
||||
prevFriendly = game.metrics.friendlyHits;
|
||||
prevMeleeHits = game.metrics.meleeHits;
|
||||
}
|
||||
|
||||
void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, float frameDt)
|
||||
{
|
||||
const Squad& squad = game.squad;
|
||||
|
||||
view.RefreshRamp(); // подкрутили гамму в оверлее — таблица перепечётся
|
||||
|
||||
// --- камера ---------------------------------------------------------------
|
||||
const Vec2 anchorView = Lerp(squad.anchorPrev, squad.anchor, alpha);
|
||||
view.cam.Follow(anchorView, frameDt, g_tune.cameraSmooth);
|
||||
|
||||
// --- события боя -> эффекты, вспышки света и кровь ------------------------
|
||||
view.fx.Ingest(game.events, squad.agents.data(), NUM_AGENTS);
|
||||
view.lighting.Ingest(game.events, squad.agents.data(), NUM_AGENTS);
|
||||
view.blood.Ingest(game.events, game.sprites, game.map);
|
||||
view.fx.Update(frameDt);
|
||||
view.atmos.Update(frameDt);
|
||||
|
||||
// --- напряжение ------------------------------------------------------------
|
||||
DreadInput di;
|
||||
di.shotsDelta = game.metrics.shotsFired - view.prevShots;
|
||||
// Отдачу посчитал fx: он один знает, из чего именно стреляли.
|
||||
di.shotShake = view.fx.shotShake;
|
||||
view.fx.shotShake = 0.0f;
|
||||
di.killsDelta = game.metrics.targetsKilled - view.prevKills;
|
||||
di.friendlyDelta = game.metrics.friendlyHits - view.prevFriendly;
|
||||
di.meleeHitsDelta = game.metrics.meleeHits - view.prevMeleeHits;
|
||||
view.prevShots = game.metrics.shotsFired;
|
||||
view.prevKills = game.metrics.targetsKilled;
|
||||
view.prevFriendly = game.metrics.friendlyHits;
|
||||
view.prevMeleeHits = game.metrics.meleeHits;
|
||||
|
||||
// «Отряд не слеп» — это и линия огня, и клинок в зоне удара: боец,
|
||||
// достающий врага, беспомощным не выглядит и напряжение снимает так же.
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
const Agent& a = squad.agents[i];
|
||||
if (a.IsMelee() ? a.melee.inReach : a.hasLane) ++di.agentsWithLane;
|
||||
}
|
||||
|
||||
for (const Target& t : game.targets)
|
||||
{
|
||||
if (!t.alive) continue;
|
||||
const float d = Distance(t.pos, squad.anchor);
|
||||
di.nearestTargetDist = std::min(di.nearestTargetDist, d);
|
||||
if (d < g_tune.dreadRadius) ++di.targetsNear;
|
||||
// Страшнее всего то, что рядом и чего не видно.
|
||||
if (view.lighting.LightAt(t.pos) < g_tune.fogEntityReveal)
|
||||
di.nearestUnseenDist = std::min(di.nearestUnseenDist, d);
|
||||
}
|
||||
if (view.horrorEnabled) view.dread.Update(di, frameDt);
|
||||
|
||||
// --- свет ------------------------------------------------------------------
|
||||
view.lighting.enabled = view.horrorEnabled;
|
||||
view.lighting.Update(squad.agents.data(), NUM_AGENTS, game.map, frameDt);
|
||||
|
||||
// --- сцена -----------------------------------------------------------------
|
||||
fb.Clear(COL_BG);
|
||||
view.scene.Begin(view.cam);
|
||||
|
||||
// Отдача камеры добавляется к смещению сцены сразу после Begin — только там
|
||||
// она согласована и с полом, и со списком сортировки, и с оверлеем.
|
||||
if (view.horrorEnabled)
|
||||
{
|
||||
int sx = 0, sy = 0;
|
||||
view.dread.CameraOffset(view.post.frame, sx, sy);
|
||||
view.scene.ox += sx;
|
||||
view.scene.oy += sy;
|
||||
}
|
||||
|
||||
DrawFloor(fb, game.sprites, game.map, view.scene.ox, view.scene.oy, view.lighting,
|
||||
&view.blood);
|
||||
CollectWalls(view.scene, game.sprites, game.map, view.lighting);
|
||||
|
||||
// Мишени: показываются только там, где светло, либо если по ним уже работает
|
||||
// кто-то из отряда (иначе стрельба в темноту выглядит абсурдом).
|
||||
for (int i = 0; i < int(game.targets.size()); ++i)
|
||||
{
|
||||
const Target& t = game.targets[i];
|
||||
if (!t.alive) continue;
|
||||
|
||||
const float lit = view.lighting.LightAt(t.pos);
|
||||
bool engaged = false;
|
||||
for (int a = 0; a < NUM_AGENTS; ++a)
|
||||
if (squad.agents[a].currentTargetId == i) { engaged = true; break; }
|
||||
|
||||
// Тело — по реальному свету: в темноте мишени физически не видно.
|
||||
if (lit >= g_tune.fogEntityReveal || engaged)
|
||||
view.scene.AddShaded(&game.sprites.target, t.pos,
|
||||
ShadeQuad::Flat(std::max(lit, engaged ? 0.30f : 0.0f)));
|
||||
}
|
||||
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
const Agent& a = squad.agents[i];
|
||||
const Vec2 p = Lerp(a.prevPos, a.pos, alpha);
|
||||
// Свои всегда читаемы: игрок обязан различать агентов даже в темноте.
|
||||
const float lit = std::max(view.lighting.LightAt(p), g_tune.agentMinLight);
|
||||
// Класс и ствол читаются силуэтом, номер — цветом.
|
||||
const Sprite& spr = a.IsMelee() ? game.sprites.melee[i]
|
||||
: (a.gunId == FirearmId::PISTOL) ? game.sprites.pistol[i]
|
||||
: game.sprites.agent[i];
|
||||
view.scene.AddShaded(&spr, p, ShadeQuad::Flat(lit));
|
||||
}
|
||||
|
||||
view.scene.Flush(fb);
|
||||
|
||||
// --- глаза мишеней --------------------------------------------------------
|
||||
// Тлеют при свете сильно ниже порога показа тела: в разведанной темноте
|
||||
// игрок сначала видит огонёк, и только подойдя — того, кому он принадлежит.
|
||||
for (const Target& t : game.targets)
|
||||
{
|
||||
if (!t.alive) continue;
|
||||
if (view.lighting.LightAt(t.pos) < g_tune.eyeRevealLight) continue;
|
||||
|
||||
// Фаза пульсации своя у каждой мишени и учащается при низком HP.
|
||||
const uint32_t seed = Hash2(uint32_t(int(t.pos.x * 4.0f)), uint32_t(int(t.pos.y * 4.0f)));
|
||||
const float hpFrac = Clampf(t.hp / std::max(1.0f, g_tune.targetHP), 0.0f, 1.0f);
|
||||
const float rate = Lerpf(3.4f, 1.1f, hpFrac);
|
||||
const float ph = Hash01(seed) + float(view.post.frame) * (rate / 60.0f);
|
||||
const float pulse = 0.55f + 0.45f * std::sin(ph * TWO_PI_F);
|
||||
|
||||
const Vec2 sp = iso::WorldToScreen(t.pos);
|
||||
const int ex = int(std::lround(sp.x)) + view.scene.ox;
|
||||
const int ey = int(std::lround(sp.y)) + view.scene.oy - int(g_tune.eyeLiftPx);
|
||||
fb.AddGlowDisc(ex, ey, int(g_tune.eyeRadiusPx) + 1,
|
||||
ScaleColor8(FXC(255, 40, 30), uint32_t(pulse * 256.0f)));
|
||||
}
|
||||
|
||||
// --- эффекты боя: аддитивно поверх геометрии, до пост-прохода ------------
|
||||
view.fx.Draw(fb, game.bullets, view.scene.ox, view.scene.oy, alpha);
|
||||
DrawMeleeArcs(fb, squad.agents.data(), NUM_AGENTS, view.scene.ox, view.scene.oy, alpha);
|
||||
|
||||
// --- взвесь в воздухе ------------------------------------------------------
|
||||
// Пепел висит МЕЖДУ камерой и сценой, поэтому рисуется последним из мира,
|
||||
// но до пост-прохода: он такой же участник кадра, как трассеры.
|
||||
if (view.horrorEnabled)
|
||||
view.atmos.DrawAsh(fb, view.cam.pos, view.scene.ox, view.scene.oy, view.lighting);
|
||||
|
||||
// --- один слитый пост-проход ----------------------------------------------
|
||||
view.post.enabled = view.horrorEnabled;
|
||||
view.post.Apply(fb, view.dread, view.atmos.time);
|
||||
}
|
||||
52
src/render/view.h
Normal file
52
src/render/view.h
Normal file
@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Всё render-only состояние живёт здесь и только здесь.
|
||||
//
|
||||
// Зачем отдельная структура: правило «эффекты не текут в симуляцию» должно
|
||||
// проверяться компилятором, а не вниманием. RenderGame принимает Game по
|
||||
// КОНСТАНТНОЙ ссылке — значит ни свет, ни трассеры, ни тряска камеры физически
|
||||
// не могут изменить состояние симуляции. Headless-прогоны (--accept, --headless)
|
||||
// View не создают вообще, поэтому цифры приёмки остаются цифрами симуляции.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "render/atmos.h"
|
||||
#include "render/blood.h"
|
||||
#include "render/framebuffer.h"
|
||||
#include "render/fx.h"
|
||||
#include "render/iso.h"
|
||||
#include "render/lighting.h"
|
||||
#include "render/post.h"
|
||||
#include "render/scene.h"
|
||||
|
||||
struct Game;
|
||||
|
||||
struct View
|
||||
{
|
||||
iso::Camera cam;
|
||||
Scene scene;
|
||||
Lighting lighting;
|
||||
FxSystem fx;
|
||||
PostFx post;
|
||||
Dread dread;
|
||||
BloodField blood; // вечные декали, запечённые в спрайты пола
|
||||
Atmosphere atmos; // пепел в воздухе
|
||||
|
||||
// Дельты считаются по накопительным счётчикам метрик: за один кадр рендера
|
||||
// проходит до MAX_STEPS_PER_FRAME шагов, поэтому флаг «выстрелил» не годится.
|
||||
int prevShots = 0;
|
||||
int prevKills = 0;
|
||||
int prevFriendly = 0;
|
||||
int prevMeleeHits = 0;
|
||||
|
||||
bool horrorEnabled = true; // F5 — аварийный возврат к прежнему кадру
|
||||
bool showSight = false; // F6 — показать диски обзора
|
||||
|
||||
// Гамма и усиление меняют САМУ таблицу рампы: без перепечатки ручки молчат.
|
||||
float rampGamma = -1.0f;
|
||||
float rampGain = -1.0f;
|
||||
|
||||
void Init();
|
||||
void RefreshRamp();
|
||||
void Reset(const Game& game);
|
||||
};
|
||||
|
||||
void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, float frameDt);
|
||||
173
src/sim/agent.cpp
Normal file
173
src/sim/agent.cpp
Normal file
@ -0,0 +1,173 @@
|
||||
#include "sim/agent.h"
|
||||
|
||||
Vec2 SeekForce(const Agent& a, Vec2 goal, float maxSpeed)
|
||||
{
|
||||
const Vec2 delta = goal - a.pos;
|
||||
const float dist = Length(delta);
|
||||
if (dist < 1e-4f) return {};
|
||||
|
||||
// У цели тормозим, иначе агент дрожит вокруг слота.
|
||||
const float speed = maxSpeed * Clampf(dist / g_tune.arriveRadius, 0.0f, 1.0f);
|
||||
return (delta / dist) * speed;
|
||||
}
|
||||
|
||||
Vec2 SeparationForce(const Agent& a, const Agent* all, int count, float maxSpeed)
|
||||
{
|
||||
const float r = g_tune.separationRadius;
|
||||
Vec2 push{};
|
||||
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
const Agent& other = all[i];
|
||||
if (&other == &a) continue;
|
||||
|
||||
const Vec2 delta = a.pos - other.pos;
|
||||
const float d = Length(delta);
|
||||
if (d >= r) continue;
|
||||
|
||||
if (d < 1e-4f)
|
||||
{
|
||||
// Идеально совпали — расталкиваем детерминированно по индексу слота.
|
||||
push += FromAngle(float(a.slotIndex) * (TWO_PI_F / NUM_AGENTS)) * maxSpeed;
|
||||
continue;
|
||||
}
|
||||
push += (delta / d) * (maxSpeed * (1.0f - d / r));
|
||||
}
|
||||
return push;
|
||||
}
|
||||
|
||||
Vec2 TargetPushForce(const Agent& a, const std::vector<Target>& targets, float maxSpeed)
|
||||
{
|
||||
Vec2 push{};
|
||||
for (const Target& t : targets)
|
||||
{
|
||||
if (!t.alive) continue;
|
||||
|
||||
const float r = t.radius + a.bodyRadius + g_tune.targetPushGap;
|
||||
const Vec2 delta = a.pos - t.pos;
|
||||
const float d = Length(delta);
|
||||
if (d >= r) continue;
|
||||
|
||||
// Ровно в центре мишени — толкаем по своему стволу, детерминированно.
|
||||
const Vec2 n = (d > 1e-4f) ? (delta / d) : FromAngle(a.facing + PI_F);
|
||||
push += n * (maxSpeed * (1.0f - d / r));
|
||||
}
|
||||
return push;
|
||||
}
|
||||
|
||||
Vec2 WallAvoidForce(const Agent& a, const Tilemap& map, Vec2 seekDir, float maxSpeed)
|
||||
{
|
||||
const float reach = a.bodyRadius + g_tune.wallLookahead;
|
||||
Vec2 push{};
|
||||
|
||||
const int x0 = int(std::floor(a.pos.x - reach));
|
||||
const int x1 = int(std::floor(a.pos.x + reach));
|
||||
const int y0 = int(std::floor(a.pos.y - reach));
|
||||
const int y1 = int(std::floor(a.pos.y + reach));
|
||||
|
||||
for (int ty = y0; ty <= y1; ++ty)
|
||||
{
|
||||
for (int tx = x0; tx <= x1; ++tx)
|
||||
{
|
||||
if (!map.IsWall(tx, ty)) continue;
|
||||
|
||||
const Vec2 closest{Clampf(a.pos.x, float(tx), float(tx + 1)),
|
||||
Clampf(a.pos.y, float(ty), float(ty + 1))};
|
||||
const Vec2 delta = a.pos - closest;
|
||||
const float d = Length(delta);
|
||||
if (d >= reach) continue;
|
||||
if (d < 1e-4f) continue; // внутри стены — этим займётся ResolveCircle
|
||||
|
||||
const Vec2 n = delta / d; // нормаль от стены к агенту
|
||||
const float w = 1.0f - d / reach; // ближе к стене — сильнее
|
||||
push += n * (maxSpeed * w);
|
||||
|
||||
// Стена стоит между агентом и целью: добавляем касательную,
|
||||
// иначе seek и отталкивание уравновешиваются и агент залипает.
|
||||
if (Dot(seekDir, n) < 0.0f)
|
||||
{
|
||||
Vec2 t = Perp(n);
|
||||
if (Dot(t, seekDir) < 0.0f) t = Vec2{-t.x, -t.y};
|
||||
push += t * (maxSpeed * w * g_tune.wallSlide);
|
||||
}
|
||||
}
|
||||
}
|
||||
return push;
|
||||
}
|
||||
|
||||
void IntegrateAgent(Agent& a, Vec2 desiredVel, float maxSpeed, float dt, const Tilemap& map,
|
||||
float accel)
|
||||
{
|
||||
desiredVel = ClampLength(desiredVel, maxSpeed);
|
||||
|
||||
// Скорость подтягивается к желаемой — резкие развороты не телепортируют агента.
|
||||
const Vec2 dv = desiredVel - a.vel;
|
||||
const float maxStep = ((accel > 0.0f) ? accel : g_tune.steerAccel) * dt;
|
||||
a.vel += ClampLength(dv, maxStep);
|
||||
a.vel = ClampLength(a.vel, maxSpeed);
|
||||
|
||||
a.pos += a.vel * dt;
|
||||
|
||||
// Скольжение вдоль стен: выталкивание гасит только нормальную компоненту.
|
||||
const Vec2 before = a.pos;
|
||||
map.ResolveCircle(a.pos, a.bodyRadius);
|
||||
const Vec2 correction = a.pos - before;
|
||||
if (LengthSq(correction) > 1e-8f)
|
||||
{
|
||||
const Vec2 n = Normalized(correction);
|
||||
const float vn = Dot(a.vel, n);
|
||||
if (vn < 0.0f) a.vel -= n * vn;
|
||||
}
|
||||
}
|
||||
|
||||
void AimAt(Agent& a, Vec2 point, float dt)
|
||||
{
|
||||
const Vec2 d = point - a.pos;
|
||||
if (LengthSq(d) < 1e-6f) return;
|
||||
a.facing = RotateToward(a.facing, AngleOf(d), g_tune.turnRateDeg * DEG2RAD_F * dt);
|
||||
}
|
||||
|
||||
void AimAlongMotion(Agent& a, float dt)
|
||||
{
|
||||
if (LengthSq(a.vel) < 0.04f) return; // почти стоит — ствол не крутим
|
||||
a.facing = RotateToward(a.facing, AngleOf(a.vel), g_tune.turnRateDeg * DEG2RAD_F * dt);
|
||||
}
|
||||
|
||||
ecs::Entity SpawnWeapon(ecs::World& world, WeaponClass cls, FirearmId gunId)
|
||||
{
|
||||
const ecs::Entity e = world.Create();
|
||||
|
||||
WeaponItem item;
|
||||
item.cls = cls;
|
||||
item.gunId = gunId;
|
||||
item.gun = FirearmDefOf(gunId);
|
||||
item.blade = DefaultBladeDef();
|
||||
world.Add<WeaponItem>(e, item);
|
||||
return e;
|
||||
}
|
||||
|
||||
void SyncLoadout(ecs::World& world, Agent& a)
|
||||
{
|
||||
a.bodyRadius = g_tune.bodyRadius;
|
||||
|
||||
const WeaponItem* item = world.Get<WeaponItem>(a.weaponEntity);
|
||||
if (!item) return; // оружия нет — бойцу нечем ни стрелять, ни бить
|
||||
|
||||
a.cls = item->cls;
|
||||
a.gunId = item->gunId;
|
||||
|
||||
// Ствол: статы обновляются целиком, состояние (патроны, откат, увод) — нет.
|
||||
// Магазин мог смениться вместе с оружием, поэтому патроны подрезаем.
|
||||
const bool sameGun = (a.gun.def.magazine == item->gun.magazine);
|
||||
a.gun.def = item->gun;
|
||||
if (!sameGun) a.gun.ammo = std::min(a.gun.ammo, a.gun.def.magazine);
|
||||
|
||||
a.melee.reach = item->blade.reach;
|
||||
a.melee.arcDeg = item->blade.arcDeg;
|
||||
a.melee.windup = item->blade.windup;
|
||||
a.melee.strike = item->blade.strike;
|
||||
a.melee.recover = item->blade.recover;
|
||||
a.melee.interval = item->blade.interval;
|
||||
a.melee.damage = item->blade.damage;
|
||||
a.melee.knockback = item->blade.knockback;
|
||||
}
|
||||
119
src/sim/agent.h
Normal file
119
src/sim/agent.h
Normal file
@ -0,0 +1,119 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Агент: состояние + слои движения (спек 6.1, 6.7).
|
||||
// Никакой изометрии, никакого рендера.
|
||||
//
|
||||
// Agent — компонент ECS. Крупный: правила боя читают состояние бойца целиком
|
||||
// и в одном порядке, дробить его на Position+Velocity+Body значило бы разложить
|
||||
// один связный алгоритм по пяти пулам без единой строчки выигрыша
|
||||
// (см. docs/12-ecs.md). А вот ОРУЖИЕ вынесено в отдельную сущность: у него
|
||||
// действительно своя жизнь, свои статы и своя подменяемость.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <vector>
|
||||
|
||||
#include "core/math.h"
|
||||
#include "ecs/ecs.h"
|
||||
#include "sim/loadout.h"
|
||||
#include "sim/target.h"
|
||||
#include "tuning.h"
|
||||
#include "world/tilemap.h"
|
||||
|
||||
enum class AgentState : uint8_t
|
||||
{
|
||||
ADVANCING = 0, // есть ввод движения
|
||||
HOLDING = 1, // ввода нет дольше holdDelay
|
||||
};
|
||||
|
||||
struct Agent
|
||||
{
|
||||
Vec2 pos{};
|
||||
Vec2 vel{};
|
||||
float facing = 0.0f; // радианы, куда смотрит ствол
|
||||
float bodyRadius = 0.28f;
|
||||
int slotIndex = 0;
|
||||
AgentState state = AgentState::HOLDING;
|
||||
int currentTargetId = -1; // -1 = нет
|
||||
Vec2 postPos{}; // выбранная огневая позиция
|
||||
float commitTimer = 0.0f; // блокировка перерешивания
|
||||
float solveTimer = 0.0f; // стаггер решателя
|
||||
|
||||
// Сущности ECS: сам боец и его оружие. Статы оружия копируются отсюда
|
||||
// в gun/melee каждый шаг (SyncLoadout), поэтому ai/ про ECS не знает вообще.
|
||||
ecs::Entity self = ecs::NO_ENTITY;
|
||||
ecs::Entity weaponEntity = ecs::NO_ENTITY;
|
||||
|
||||
WeaponClass cls = WeaponClass::RANGED;
|
||||
FirearmId gunId = FirearmId::RIFLE;
|
||||
Firearm gun; // работает только у RANGED
|
||||
MeleeWeapon melee; // работает только у MELEE
|
||||
|
||||
// --- служебное (рендер/отладка/метрики), на правила не влияет ---
|
||||
Vec2 prevPos{}; // для интерполяции кадра
|
||||
bool hasLane = false; // есть валидная линия огня прямо сейчас
|
||||
bool priorityLane = false; // линия именно к приоритетной цели (6.8)
|
||||
bool laneIntent = false; // цель есть, мешает ТОЛЬКО свой -> линия резервируется
|
||||
bool targetBlockedByAlly = false; // цель есть, линию перекрыл свой
|
||||
bool targetBlockedByWall = false; // цель есть, линию перекрыла стена
|
||||
Vec2 laneEnd{}; // куда указывает текущая линия
|
||||
bool fired = false; // выстрелил на этом шаге (для оверлея)
|
||||
|
||||
float Speed() const { return Length(vel); }
|
||||
|
||||
// Полураствор конуса выстрела прямо сейчас. Стоя он почти нулевой, на ходу
|
||||
// растёт. Ровно этот конус проверяется на своих перед спуском.
|
||||
float SpreadRad() const { return gun.SpreadRad(Speed()); }
|
||||
|
||||
// Точка вылета пули: muzzleOffset юнита от центра в сторону towards.
|
||||
Vec2 MuzzleTowards(Vec2 towards) const
|
||||
{
|
||||
const Vec2 d = Normalized(towards - pos, FromAngle(facing));
|
||||
return pos + d * gun.def.muzzleOffset;
|
||||
}
|
||||
Vec2 MuzzleFacing() const { return pos + FromAngle(facing) * gun.def.muzzleOffset; }
|
||||
|
||||
bool IsMelee() const { return cls == WeaponClass::MELEE; }
|
||||
|
||||
// Множитель скорости от фазы удара: на замахе ноги вкопаны, после удара
|
||||
// боец медленный. Рывок в STRIKE считается отдельно, здесь его нет.
|
||||
float SpeedScale() const
|
||||
{
|
||||
if (cls != WeaponClass::MELEE) return 1.0f;
|
||||
switch (melee.state)
|
||||
{
|
||||
case MeleeState::WINDUP: return g_tune.meleeWindupScale;
|
||||
case MeleeState::RECOVER: return g_tune.meleeRecoverScale;
|
||||
default: return 1.0f;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- слои движения (6.7) -----------------------------------------------------
|
||||
|
||||
// 1. seek с торможением у цели
|
||||
Vec2 SeekForce(const Agent& a, Vec2 goal, float maxSpeed);
|
||||
// 2. расталкивание от союзников
|
||||
Vec2 SeparationForce(const Agent& a, const Agent* all, int count, float maxSpeed);
|
||||
// 3'. расталкивание от мишеней: чужое тело — не пустое место. Без этого боец
|
||||
// ближнего боя стоит ВНУТРИ того, кого рубит, и удар не читается.
|
||||
Vec2 TargetPushForce(const Agent& a, const std::vector<Target>& targets, float maxSpeed);
|
||||
// 4. отталкивание от стен + скольжение вдоль них.
|
||||
// seekDir нужен, чтобы обходить препятствие, а не упираться в него лбом:
|
||||
// без тангенциальной составляющей агент залипает в «тени» стены.
|
||||
Vec2 WallAvoidForce(const Agent& a, const Tilemap& map, Vec2 seekDir, float maxSpeed);
|
||||
|
||||
// 5. клампинг, интеграция, разрешение коллизий с тайлами.
|
||||
// accel = -1 означает «взять steerAccel из tuning»; рывку клинка нужен свой.
|
||||
void IntegrateAgent(Agent& a, Vec2 desiredVel, float maxSpeed, float dt, const Tilemap& map,
|
||||
float accel = -1.0f);
|
||||
|
||||
// Доворот ствола к точке (или по вектору движения, если цели нет).
|
||||
void AimAt(Agent& a, Vec2 point, float dt);
|
||||
void AimAlongMotion(Agent& a, float dt);
|
||||
|
||||
// Система: скопировать статы с сущности-оружия и общие размеры из tuning.
|
||||
// Рабочее состояние (патроны, фаза удара, откат, увод ствола) НЕ трогается.
|
||||
void SyncLoadout(ecs::World& world, Agent& a);
|
||||
|
||||
// Создать сущность оружия нужного класса и вернуть её. Стволы и клинки живут
|
||||
// в одном пуле WeaponItem: подменить одно другим — это одна запись компонента.
|
||||
ecs::Entity SpawnWeapon(ecs::World& world, WeaponClass cls, FirearmId gunId);
|
||||
135
src/sim/bullet.cpp
Normal file
135
src/sim/bullet.cpp
Normal file
@ -0,0 +1,135 @@
|
||||
#include "sim/bullet.h"
|
||||
|
||||
#include "debug/metrics.h"
|
||||
|
||||
void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel,
|
||||
int owner)
|
||||
{
|
||||
std::vector<Bullet>& bullets = world.All<Bullet>();
|
||||
if (int(bullets.size()) >= MAX_BULLETS) return;
|
||||
|
||||
Bullet b;
|
||||
b.self = world.Create();
|
||||
b.pos = from;
|
||||
b.prevPos = from;
|
||||
b.origin = from;
|
||||
b.vel = Normalized(dir) * def.bulletSpeed;
|
||||
b.damage = def.damage;
|
||||
b.knockback = def.knockback;
|
||||
b.radius = def.bulletRadius;
|
||||
// Живёт ровно столько, чтобы пройти проверенный отрезок (+ радиус мишени).
|
||||
const float reach = std::min(travel + g_tune.targetRadius, def.range);
|
||||
b.life = reach / std::max(0.001f, def.bulletSpeed);
|
||||
b.owner = owner;
|
||||
b.tracerLength = def.tracerLength;
|
||||
b.tracerR = def.tracerR;
|
||||
b.tracerG = def.tracerG;
|
||||
b.tracerB = def.tracerB;
|
||||
|
||||
world.Add<Bullet>(b.self, b);
|
||||
}
|
||||
|
||||
void UpdateBullets(ecs::World& world, const Tilemap& map,
|
||||
Agent* agents, int agentCount,
|
||||
std::vector<Target>& targets, float dt, Metrics& metrics,
|
||||
CombatEventLog* events)
|
||||
{
|
||||
std::vector<Bullet>& bullets = world.All<Bullet>();
|
||||
|
||||
// Убирать сущности прямо в цикле нельзя: удаление из пула переставляет
|
||||
// хвост на освободившееся место. Поэтому сначала шаг, потом похороны.
|
||||
ecs::Entity dead[MAX_BULLETS];
|
||||
int deadCount = 0;
|
||||
|
||||
for (size_t bi = 0; bi < bullets.size(); ++bi)
|
||||
{
|
||||
Bullet& b = bullets[bi];
|
||||
const float br = b.radius;
|
||||
|
||||
b.prevPos = b.pos;
|
||||
b.pos += b.vel * dt;
|
||||
b.life -= dt;
|
||||
|
||||
// Ищем самое раннее попадание вдоль пройденного за шаг отрезка.
|
||||
float tBest = 2.0f;
|
||||
int hitAgent = -1;
|
||||
int hitTarget = -1;
|
||||
|
||||
float tWall = 0.0f;
|
||||
if (map.RaycastBlocked(b.prevPos, b.pos, &tWall)) tBest = tWall;
|
||||
|
||||
if (g_tune.friendlyFire)
|
||||
{
|
||||
for (int i = 0; i < agentCount; ++i)
|
||||
{
|
||||
if (i == b.owner) continue; // своя пуля стрелка не бьёт
|
||||
float t = 0.0f;
|
||||
const Vec2 cp = ClosestPointOnSegment(agents[i].pos, b.prevPos, b.pos, &t);
|
||||
if (Distance(cp, agents[i].pos) > agents[i].bodyRadius + br) continue;
|
||||
if (t < tBest)
|
||||
{
|
||||
tBest = t;
|
||||
hitAgent = i;
|
||||
hitTarget = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < int(targets.size()); ++i)
|
||||
{
|
||||
if (!targets[i].alive) continue;
|
||||
float t = 0.0f;
|
||||
const Vec2 cp = ClosestPointOnSegment(targets[i].pos, b.prevPos, b.pos, &t);
|
||||
if (Distance(cp, targets[i].pos) > targets[i].radius + br) continue;
|
||||
if (t < tBest)
|
||||
{
|
||||
tBest = t;
|
||||
hitTarget = i;
|
||||
hitAgent = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Точка события — там, где пуля реально столкнулась, а не b.pos:
|
||||
// на кадре смерти b.pos уже за стеной/внутри цели.
|
||||
const Vec2 impact = Lerp(b.prevPos, b.pos, Clampf(tBest, 0.0f, 1.0f));
|
||||
const Vec2 dir = Normalized(b.vel);
|
||||
|
||||
if (hitAgent >= 0)
|
||||
{
|
||||
metrics.LogFriendlyHit(b.owner, hitAgent, agents[hitAgent].pos,
|
||||
agents[hitAgent].state == AgentState::ADVANCING);
|
||||
if (events) events->Push(CombatEventKind::HIT_ALLY, b.owner, impact, dir);
|
||||
dead[deadCount++] = b.self;
|
||||
continue;
|
||||
}
|
||||
if (hitTarget >= 0)
|
||||
{
|
||||
Target& t = targets[hitTarget];
|
||||
t.hp -= b.damage;
|
||||
KnockTarget(t, dir, b.knockback);
|
||||
if (events) events->Push(CombatEventKind::HIT_TARGET, b.owner, impact, dir);
|
||||
if (t.hp <= 0.0f)
|
||||
{
|
||||
t.alive = false;
|
||||
t.respawnTimer = g_tune.targetRespawn;
|
||||
++metrics.targetsKilled;
|
||||
if (events) events->Push(CombatEventKind::HIT_KILL, b.owner, t.pos, dir);
|
||||
}
|
||||
dead[deadCount++] = b.self;
|
||||
continue;
|
||||
}
|
||||
// Стена и «выработала дистанцию» — разные события для эффектов.
|
||||
if (tBest <= 1.0f)
|
||||
{
|
||||
if (events) events->Push(CombatEventKind::HIT_WALL, b.owner, impact, dir);
|
||||
dead[deadCount++] = b.self;
|
||||
}
|
||||
else if (b.life <= 0.0f)
|
||||
{
|
||||
if (events) events->Push(CombatEventKind::EXPIRED, b.owner, b.pos, dir);
|
||||
dead[deadCount++] = b.self;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < deadCount; ++i) world.Destroy(dead[i]);
|
||||
}
|
||||
54
src/sim/bullet.h
Normal file
54
src/sim/bullet.h
Normal file
@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Пули — настоящие снаряды (спек 6.9), не хитскан.
|
||||
// Коллизия с тайлами и со ВСЕМИ капсулами, включая союзников: friendlyFire
|
||||
// физически возможен, иначе прототип ничего не доказывает.
|
||||
//
|
||||
// Bullet — компонент ECS: у каждой пули своя сущность, которая умирает вместе
|
||||
// с ней. Свободный список больше не нужен — его роль играет пул.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <vector>
|
||||
|
||||
#include "core/math.h"
|
||||
#include "ecs/ecs.h"
|
||||
#include "sim/agent.h"
|
||||
#include "sim/events.h"
|
||||
#include "sim/target.h"
|
||||
#include "world/tilemap.h"
|
||||
|
||||
struct Metrics;
|
||||
|
||||
struct Bullet
|
||||
{
|
||||
ecs::Entity self = ecs::NO_ENTITY;
|
||||
|
||||
Vec2 pos{};
|
||||
Vec2 prevPos{};
|
||||
Vec2 vel{};
|
||||
Vec2 origin{}; // откуда вылетела — только для эффектов, правила не читают
|
||||
float damage = 0.0f;
|
||||
float knockback = 0.0f;
|
||||
float radius = 0.04f;
|
||||
float life = 0.0f; // сек до самоуничтожения (дальность / скорость)
|
||||
int owner = -1;
|
||||
|
||||
// Визуал трассера едет вместе со снарядом: он у каждого ствола свой,
|
||||
// а к моменту отрисовки стрелок мог уже сменить оружие или умереть.
|
||||
float tracerLength = 1.3f;
|
||||
uint8_t tracerR = 255, tracerG = 240, tracerB = 205;
|
||||
};
|
||||
|
||||
// travel — длина ПРОВЕРЕННОГО отрезка (дуло -> цель). Пуля не летит дальше:
|
||||
// за целью пространство никто не проверял, и если цель успела умереть,
|
||||
// пуля ушла бы в непроверенную зону и могла бы найти там своего.
|
||||
//
|
||||
// dir — уже с учётом разброса. Конус разброса проверен на своих ДО спуска
|
||||
// (см. ai/firing_solver.cpp), поэтому отклонившаяся пуля своего не найдёт.
|
||||
void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel,
|
||||
int owner);
|
||||
|
||||
// events == nullptr в headless-прогонах: эффектов там нет, а правила журнал не читают.
|
||||
void UpdateBullets(ecs::World& world, const Tilemap& map,
|
||||
Agent* agents, int agentCount,
|
||||
std::vector<Target>& targets, float dt, Metrics& metrics,
|
||||
CombatEventLog* events);
|
||||
55
src/sim/events.h
Normal file
55
src/sim/events.h
Normal file
@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Единственный канал «симуляция -> визуальные эффекты».
|
||||
//
|
||||
// Почему кольцевой журнал, а не флаги вроде Agent::fired: за один кадр рендера
|
||||
// проходит ДО MAX_STEPS_PER_FRAME шагов симуляции. Флаг, выставленный на шаге 1
|
||||
// и затёртый на шаге 2, эффект не увидит. Журнал переживает все шаги кадра.
|
||||
//
|
||||
// Симуляция только ПИШЕТ сюда. Ни одно правило из спека 6 журнал не читает,
|
||||
// поэтому на детерминизм и на критерии приёмки он не влияет. В headless-прогонах
|
||||
// указатель на журнал равен nullptr.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/math.h"
|
||||
|
||||
enum class CombatEventKind : uint8_t
|
||||
{
|
||||
SHOT = 0, // выстрел: pos = дуло, dir = направление
|
||||
HIT_WALL, // пуля в стену
|
||||
HIT_TARGET, // пуля в мишень
|
||||
HIT_KILL, // мишень уничтожена
|
||||
HIT_ALLY, // попадание по своему (в норме не должно случаться никогда)
|
||||
EXPIRED, // пуля выработала проверенную дистанцию
|
||||
MELEE_SWING, // взмах пошёл: pos = боец, dir = ось удара
|
||||
MELEE_HIT, // лезвие достало мишень
|
||||
MELEE_ABORT, // взмах оборван: в дугу влез свой
|
||||
RELOAD_START, // магазин пуст, боец пошёл перезаряжаться
|
||||
RELOAD_DONE, // магазин снаряжён
|
||||
};
|
||||
|
||||
struct CombatEvent
|
||||
{
|
||||
CombatEventKind kind = CombatEventKind::SHOT;
|
||||
int owner = -1; // индекс агента-стрелка
|
||||
Vec2 pos{};
|
||||
Vec2 dir{};
|
||||
};
|
||||
|
||||
// Кольцо: если эффекты за кадр не успели забрать события, теряются самые старые.
|
||||
constexpr int COMBAT_EVENT_CAP = 64;
|
||||
|
||||
struct CombatEventLog
|
||||
{
|
||||
CombatEvent buf[COMBAT_EVENT_CAP];
|
||||
uint32_t head = 0; // всего записано за всё время; индекс = head % CAP
|
||||
|
||||
void Clear() { head = 0; }
|
||||
|
||||
void Push(CombatEventKind kind, int owner, Vec2 pos, Vec2 dir)
|
||||
{
|
||||
buf[head % COMBAT_EVENT_CAP] = CombatEvent{kind, owner, pos, dir};
|
||||
++head;
|
||||
}
|
||||
};
|
||||
177
src/sim/loadout.h
Normal file
177
src/sim/loadout.h
Normal file
@ -0,0 +1,177 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Снаряжение бойца: РАБОЧЕЕ состояние оружия.
|
||||
//
|
||||
// Разделение простое и важное:
|
||||
// * ЧТО такое ствол (дальность, урон, разброс, магазин, вид вспышки) —
|
||||
// это FirearmDef на сущности-оружии, sim/weapon_defs.h;
|
||||
// * В КАКОМ ОН СЕЙЧАС СОСТОЯНИИ (откат, патроны, перезарядка, увод ствола) —
|
||||
// это Firearm здесь, и живёт оно на бойце.
|
||||
// Подменили сущность оружия — поменялись все статы разом, состояние обнулилось,
|
||||
// и ни одно правило боя об этом не узнало.
|
||||
//
|
||||
// Правила боя от класса НЕ зависят. И стрелок, и боец ближнего боя обязаны
|
||||
// проверить своих перед ударом; меняется только геометрия проверки —
|
||||
// конус «дуло -> цель» против сектора замаха (docs/11-melee.md).
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/hash.h"
|
||||
#include "core/math.h"
|
||||
#include "sim/weapon_defs.h"
|
||||
#include "tuning.h"
|
||||
|
||||
// --- дальнобойное ------------------------------------------------------------
|
||||
|
||||
struct Firearm
|
||||
{
|
||||
FirearmDef def{}; // копия статов сущности-оружия, обновляется каждый шаг
|
||||
|
||||
// --- состояние ---
|
||||
float cooldown = 0.0f;
|
||||
int ammo = 0;
|
||||
float reloadTimer = 0.0f; // > 0 — идёт перезарядка
|
||||
float recoilDeg = 0.0f; // увод ствола, гаснет сам
|
||||
uint32_t shotSeq = 0; // номер выстрела: сеет детерминированный разброс
|
||||
|
||||
bool Reloading() const { return reloadTimer > 0.0f; }
|
||||
bool Empty() const { return ammo <= 0; }
|
||||
|
||||
// Доля магазина — для оверлея и HUD.
|
||||
float AmmoFrac() const
|
||||
{
|
||||
return (def.magazine > 0) ? Clampf(float(ammo) / float(def.magazine), 0.0f, 1.0f) : 0.0f;
|
||||
}
|
||||
|
||||
// Разброс в радианах: стоя — минимальный, на ходу растёт до spreadMoveDeg,
|
||||
// сверху ложится накопленный увод ствола.
|
||||
//
|
||||
// Это ЕДИНСТВЕННОЕ место, где считается разброс. Его же читает проверка
|
||||
// линии огня: конус, по которому реально уйдёт пуля, обязан быть тем самым
|
||||
// конусом, который проверили на своих (см. ai/firing_solver.cpp).
|
||||
float SpreadRad(float speed) const
|
||||
{
|
||||
const float m = Clampf(speed / std::max(0.01f, MaxAgentSpeed()), 0.0f, 1.0f);
|
||||
const float deg = Lerpf(def.spreadStandDeg, def.spreadMoveDeg, m) + recoilDeg;
|
||||
return std::max(0.0f, deg) * DEG2RAD_F * std::max(0.0f, g_tune.spreadScale);
|
||||
}
|
||||
|
||||
// Отклонение конкретного выстрела внутри конуса. Не генератор: чистый хеш
|
||||
// от (владелец, номер выстрела), поэтому прогон воспроизводим побитово.
|
||||
float ShotOffset(int ownerIndex, float spreadRad) const
|
||||
{
|
||||
const uint32_t h = Hash2(uint32_t(ownerIndex) * 2654435761u, shotSeq);
|
||||
// Треугольное распределение: центр конуса вероятнее краёв.
|
||||
const float a = HashSigned(h);
|
||||
const float b = HashSigned(HashU32(h ^ 0x9E3779B9u));
|
||||
return 0.5f * (a + b) * spreadRad;
|
||||
}
|
||||
};
|
||||
|
||||
// --- ближний бой -------------------------------------------------------------
|
||||
|
||||
// Три фазы, как в любом ощутимом ударе: замах видно заранее, урон живёт
|
||||
// в коротком окне, после удара боец открыт. Без разделения удар превращается
|
||||
// в мгновенный хитскан и не читается на 480x270.
|
||||
enum class MeleeState : uint8_t
|
||||
{
|
||||
IDLE = 0, // подходит или ждёт перезарядку
|
||||
WINDUP, // замах: ноги вкопаны, ось удара уже зафиксирована
|
||||
STRIKE, // активное окно: лезвие метёт дугу, есть рывок вперёд
|
||||
RECOVER, // после удара — уязвим и медленный
|
||||
};
|
||||
|
||||
struct MeleeWeapon
|
||||
{
|
||||
// --- параметры (копируются с сущности-оружия каждый шаг) ---
|
||||
float reach = 1.15f; // от центра тела до конца замаха
|
||||
float arcDeg = 100.0f; // ширина сектора
|
||||
float windup = 0.18f;
|
||||
float strike = 0.12f;
|
||||
float recover = 0.26f;
|
||||
float interval = 0.62f; // между началами замахов
|
||||
float damage = 110.0f;
|
||||
float knockback = 2.4f; // импульс мишени ДО её сопротивления отбросу
|
||||
|
||||
// --- состояние ---
|
||||
MeleeState state = MeleeState::IDLE;
|
||||
float phase = 0.0f; // остаток текущей фазы, сек
|
||||
float cooldown = 0.0f;
|
||||
float dir = 0.0f; // ось удара, зафиксирована на замахе
|
||||
float sweepSign = 1.0f; // с какой стороны заходит лезвие (чередуется)
|
||||
int targetId = -1;
|
||||
bool inReach = false; // цель в зоне удара прямо сейчас
|
||||
bool arcBlockedByAlly = false; // в секторе свой -> бить нельзя (аналог 6.3)
|
||||
uint32_t hitMask = 0; // кого уже задели ЭТИМ взмахом
|
||||
|
||||
// --- связка ---
|
||||
int comboStep = 0; // какой удар связки будет следующим
|
||||
float comboTimer = 0.0f; // сколько ещё держится связка
|
||||
|
||||
// --- запечённые параметры ТЕКУЩЕГО взмаха ---
|
||||
// Связка гнёт ширину, урон и тайминги, а поля выше переписываются из
|
||||
// снаряжения КАЖДЫЙ шаг (SyncLoadoutFromTuning). Поэтому значения взмаха
|
||||
// фиксируются на старте замаха и живут отдельно — иначе удар менял бы
|
||||
// свойства прямо в полёте.
|
||||
float curHalfArc = 0.0f;
|
||||
float curDamage = 0.0f;
|
||||
float curKnock = 0.0f;
|
||||
float curWindup = 0.0f;
|
||||
float curStrike = 0.0f;
|
||||
float curRecover = 0.0f;
|
||||
|
||||
bool Committed() const
|
||||
{
|
||||
return state == MeleeState::WINDUP || state == MeleeState::STRIKE;
|
||||
}
|
||||
|
||||
const MeleeComboStep& NextComboStep() const
|
||||
{
|
||||
return MELEE_COMBO[(comboStep >= 0 && comboStep < MELEE_COMBO_STEPS) ? comboStep : 0];
|
||||
}
|
||||
|
||||
// Полуширина сектора. Во время взмаха — запечённая, иначе — та, что будет
|
||||
// у СЛЕДУЮЩЕГО удара связки. Все потребители (проверка сектора, дуга,
|
||||
// оверлей, резерв линии) обязаны видеть одно и то же число: иначе
|
||||
// нарисованное и то, что бьёт, разъедутся.
|
||||
float HalfArc() const
|
||||
{
|
||||
if (Committed() || state == MeleeState::RECOVER) return curHalfArc;
|
||||
return arcDeg * 0.5f * DEG2RAD_F * NextComboStep().arcMul;
|
||||
}
|
||||
|
||||
// Запечь параметры взмаха из базовых характеристик и шага связки.
|
||||
void BakeSwing(const MeleeComboStep& cs)
|
||||
{
|
||||
curHalfArc = arcDeg * 0.5f * DEG2RAD_F * cs.arcMul;
|
||||
curDamage = damage * cs.dmgMul;
|
||||
curKnock = knockback * cs.knockMul;
|
||||
curWindup = windup * cs.windupMul;
|
||||
curStrike = strike * cs.strikeMul;
|
||||
curRecover = recover * cs.recoverMul;
|
||||
}
|
||||
|
||||
float PhaseLen() const
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case MeleeState::WINDUP: return curWindup;
|
||||
case MeleeState::STRIKE: return curStrike;
|
||||
case MeleeState::RECOVER: return curRecover;
|
||||
default: return 1.0f;
|
||||
}
|
||||
}
|
||||
|
||||
// 0 в начале фазы, 1 в конце.
|
||||
float Progress() const
|
||||
{
|
||||
return Clampf(1.0f - phase / std::max(1e-4f, PhaseLen()), 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
// Где лезвие при заданном прогрессе взмаха: от одного края сектора к другому.
|
||||
float BladeAngleAt(float progress) const
|
||||
{
|
||||
const float half = curHalfArc;
|
||||
return WrapPi(dir + sweepSign * (-half + 2.0f * half * progress));
|
||||
}
|
||||
};
|
||||
291
src/sim/squad.cpp
Normal file
291
src/sim/squad.cpp
Normal file
@ -0,0 +1,291 @@
|
||||
#include "sim/squad.h"
|
||||
|
||||
#include "ai/lane_registry.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Локальные слоты строя: x = вперёд, y = вправо. Множатся на formationSpacing.
|
||||
const float kWedge[NUM_AGENTS][2] = {
|
||||
{ 1.0f, 0.0f},
|
||||
{ 0.0f, -0.9f},
|
||||
{ 0.0f, 0.9f},
|
||||
{-1.0f, -1.8f},
|
||||
{-1.0f, 1.8f},
|
||||
};
|
||||
|
||||
const float kLine[NUM_AGENTS][2] = {
|
||||
{0.0f, -2.0f},
|
||||
{0.0f, -1.0f},
|
||||
{0.0f, 0.0f},
|
||||
{0.0f, 1.0f},
|
||||
{0.0f, 2.0f},
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* FormationName(Formation f)
|
||||
{
|
||||
switch (f)
|
||||
{
|
||||
case Formation::WEDGE: return "WEDGE";
|
||||
case Formation::LINE: return "LINE";
|
||||
case Formation::RING: return "RING";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
void Squad::Init(ecs::World& world, Vec2 at)
|
||||
{
|
||||
anchor = at;
|
||||
anchorPrev = at;
|
||||
anchorVel = {};
|
||||
forward = {1.0f, 0.0f};
|
||||
state = AgentState::HOLDING;
|
||||
idleTimer = g_tune.holdDelay;
|
||||
holdElapsed = 0.0f;
|
||||
urgency = 0.0f;
|
||||
sprinting = false;
|
||||
formSpacing = 1.0f;
|
||||
formStretch = 1.0f;
|
||||
|
||||
meleeCount = std::min(std::max(meleeCount, 0), NUM_AGENTS);
|
||||
pistolCount = std::min(std::max(pistolCount, 0), NUM_AGENTS - meleeCount);
|
||||
|
||||
// Сначала создаём все сущности бойцов, и только потом берём указатель на
|
||||
// плотный массив пула: до конца заполнения он может переехать.
|
||||
ecs::Entity ids[NUM_AGENTS];
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
ids[i] = world.Create();
|
||||
world.Add<Agent>(ids[i], Agent{});
|
||||
}
|
||||
agents.p = world.All<Agent>().data();
|
||||
agents.n = NUM_AGENTS;
|
||||
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
Agent& a = agents[i];
|
||||
a = Agent{};
|
||||
a.self = ids[i];
|
||||
a.slotIndex = i;
|
||||
|
||||
// Клинки занимают первые слоты: в клине это остриё и левое крыло,
|
||||
// то есть ровно те, кто и так упирается в противника первым.
|
||||
// Пистолеты — последние слоты: короткий ствол уезжает в хвост клина.
|
||||
const bool isMelee = (i < meleeCount);
|
||||
const bool isPistol = !isMelee && (i >= NUM_AGENTS - pistolCount);
|
||||
|
||||
a.cls = isMelee ? WeaponClass::MELEE : WeaponClass::RANGED;
|
||||
a.gunId = isPistol ? FirearmId::PISTOL : FirearmId::RIFLE;
|
||||
a.weaponEntity = SpawnWeapon(world, a.cls, a.gunId);
|
||||
|
||||
a.melee.sweepSign = (i & 1) ? -1.0f : 1.0f;
|
||||
a.pos = SlotWorld(i);
|
||||
a.prevPos = a.pos;
|
||||
a.postPos = a.pos;
|
||||
a.facing = 0.0f;
|
||||
a.state = state;
|
||||
a.currentTargetId = -1;
|
||||
a.commitTimer = 0.0f;
|
||||
// Фазы решателя разведены: за кадр решает максимум один агент (6.5).
|
||||
a.solveTimer = g_tune.solveInterval * (float(i) / float(NUM_AGENTS));
|
||||
|
||||
SyncLoadout(world, a);
|
||||
a.gun.ammo = a.gun.def.magazine; // выходим с полным магазином
|
||||
}
|
||||
}
|
||||
|
||||
Vec2 Squad::SlotLocal(int i) const
|
||||
{
|
||||
// Интервал и вытяжение — это ТЕКУЩЕЕ состояние строя, а не константа:
|
||||
// на стоянке отряд собирается, в беге растягивается вдоль движения.
|
||||
const float s = g_tune.formationSpacing * formSpacing;
|
||||
const float fwd = s * formStretch;
|
||||
|
||||
switch (formation)
|
||||
{
|
||||
case Formation::LINE:
|
||||
return {kLine[i][0] * fwd, kLine[i][1] * s};
|
||||
case Formation::RING:
|
||||
{
|
||||
const float ang = float(i) * (TWO_PI_F / float(NUM_AGENTS));
|
||||
const Vec2 d = FromAngle(ang) * 1.5f;
|
||||
return {d.x * fwd, d.y * s};
|
||||
}
|
||||
case Formation::WEDGE:
|
||||
default:
|
||||
return {kWedge[i][0] * fwd, kWedge[i][1] * s};
|
||||
}
|
||||
}
|
||||
|
||||
Vec2 Squad::SlotWorld(int i) const
|
||||
{
|
||||
const Vec2 local = SlotLocal(i);
|
||||
const Vec2 right = Perp(forward);
|
||||
return anchor + forward * local.x + right * local.y;
|
||||
}
|
||||
|
||||
Vec2 Squad::GoalFor(int i) const
|
||||
{
|
||||
// Боец ближнего боя ведёт себя одинаково в обоих состояниях: пока цель
|
||||
// внутри его круга, он идёт к ней, а не в слот. Иначе клинок оказывался бы
|
||||
// полезен только на стоянке — а он нужен как раз в движении.
|
||||
// Но в спринте цель отменяется: бежим, а не деремся.
|
||||
if (agents[i].IsMelee() && !sprinting) return agents[i].postPos;
|
||||
if (agents[i].IsMelee()) return SlotWorld(i);
|
||||
|
||||
// В движении — слот строя, на месте — выбранная решателем огневая позиция.
|
||||
return (state == AgentState::ADVANCING) ? SlotWorld(i) : agents[i].postPos;
|
||||
}
|
||||
|
||||
Vec2 Squad::Centroid() const
|
||||
{
|
||||
Vec2 c{};
|
||||
for (int i = 0; i < NUM_AGENTS; ++i) c += agents[i].pos;
|
||||
return c / float(NUM_AGENTS);
|
||||
}
|
||||
|
||||
void Squad::UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap& map, float dt)
|
||||
{
|
||||
anchorPrev = anchor;
|
||||
|
||||
const bool moving = LengthSq(moveWorldDir) > 1e-6f;
|
||||
const AgentState prevState = state;
|
||||
const Vec2 c = Centroid();
|
||||
|
||||
// --- натяжение поводка ----------------------------------------------------
|
||||
// Поводок УПРУГИЙ, а не жёсткий. За мягкой границей курсор продолжает
|
||||
// уходить, но идёт всё тяжелее, и до жёсткого предела не доходит никогда.
|
||||
//
|
||||
// Жёсткий поводок здесь не годился принципиально: он мгновенно упирается,
|
||||
// дистанция намертво встаёт в одно значение, и по ней уже нельзя отличить
|
||||
// «подправил направление» от «тяну отряд вперёд изо всех сил». Упругий
|
||||
// поводок превращает дистанцию в непрерывную величину: чем сильнее тянешь,
|
||||
// тем дальше уезжает курсор и тем быстрее бегут за ним.
|
||||
const float soft = std::max(0.2f, g_tune.anchorLeash);
|
||||
const float hard = std::max(soft + 0.2f, g_tune.anchorLeashMax);
|
||||
const float start = soft * Clampf(g_tune.urgeStartFrac, 0.0f, 0.95f);
|
||||
const float dist = Distance(anchor, c);
|
||||
|
||||
float pull = 0.0f;
|
||||
if (moving) pull = Clampf((dist - start) / (hard - start), 0.0f, 1.0f);
|
||||
if (moving && sprint) pull = 1.0f; // Shift — приказ бежать, без уговоров
|
||||
|
||||
const float rate = (pull > urgency) ? g_tune.urgeAttack : g_tune.urgeRelease;
|
||||
urgency += (pull - urgency) * (1.0f - std::exp(-std::max(0.01f, rate) * dt));
|
||||
urgency = Clampf(urgency, 0.0f, 1.0f);
|
||||
// Бег без стрельбы — либо по прямому приказу, либо когда курсор реально
|
||||
// уехал: отряд догоняет и ему не до целей.
|
||||
sprinting = (moving && sprint) || (urgency >= g_tune.sprintAt);
|
||||
|
||||
// --- сам курсор -----------------------------------------------------------
|
||||
// У него есть разгон и торможение: мгновенная смена скорости — это и есть
|
||||
// «топорно». Отпустил клавиши — курсор доезжает, а не втыкается в грунт.
|
||||
const float over = Clampf((dist - soft) / (hard - soft), 0.0f, 1.0f);
|
||||
const float dragK = std::max(0.08f, 1.0f - Clampf(g_tune.leashDrag, 0.0f, 1.0f) * over);
|
||||
const float speed = (sprint ? g_tune.anchorSpeedFast : g_tune.anchorSpeed) * dragK;
|
||||
|
||||
const Vec2 want = moving ? Normalized(moveWorldDir, forward) * speed : Vec2{};
|
||||
const Vec2 dv = want - anchorVel;
|
||||
anchorVel += ClampLength(dv, std::max(0.1f, g_tune.anchorAccel) * dt);
|
||||
anchor += anchorVel * dt;
|
||||
|
||||
if (moving) forward = Normalized(moveWorldDir, forward);
|
||||
|
||||
// Жёсткий предел. Сопротивление обычно не даёт до него дойти, но отряд может
|
||||
// и застрять в стене — тогда курсор без предела уехал бы за полкарты.
|
||||
const Vec2 d = anchor - c;
|
||||
const float len = Length(d);
|
||||
if (len > hard && len > 1e-5f)
|
||||
{
|
||||
const Vec2 n = d / len;
|
||||
anchor = c + n * hard;
|
||||
const float vn = Dot(anchorVel, n);
|
||||
if (vn > 0.0f) anchorVel -= n * vn;
|
||||
}
|
||||
|
||||
map.ResolveCircle(anchor, g_tune.anchorRadius);
|
||||
|
||||
// --- состояние отряда ------------------------------------------------------
|
||||
if (moving)
|
||||
{
|
||||
idleTimer = 0.0f;
|
||||
state = AgentState::ADVANCING;
|
||||
holdElapsed = 0.0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
idleTimer += dt;
|
||||
if (idleTimer >= g_tune.holdDelay) state = AgentState::HOLDING;
|
||||
if (state == AgentState::HOLDING) holdElapsed += dt;
|
||||
}
|
||||
|
||||
// --- дыхание строя ---------------------------------------------------------
|
||||
const float wantSpacing = (state == AgentState::HOLDING)
|
||||
? g_tune.holdSpacing
|
||||
: Lerpf(1.0f, g_tune.sprintSpacing, urgency);
|
||||
const float wantStretch = Lerpf(1.0f, g_tune.sprintStretch, urgency);
|
||||
const float k = 1.0f - std::exp(-std::max(0.01f, g_tune.formMorphRate) * dt);
|
||||
formSpacing += (wantSpacing - formSpacing) * k;
|
||||
formStretch += (wantStretch - formStretch) * k;
|
||||
|
||||
if (state != prevState)
|
||||
{
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
Agent& a = agents[i];
|
||||
a.commitTimer = 0.0f;
|
||||
// Встали — остаёмся на месте, пока решатель не найдёт лучше (6.5).
|
||||
if (state == AgentState::HOLDING) a.postPos = a.pos;
|
||||
}
|
||||
if (state == AgentState::HOLDING) holdElapsed = 0.0f;
|
||||
}
|
||||
|
||||
for (int i = 0; i < NUM_AGENTS; ++i) agents[i].state = state;
|
||||
}
|
||||
|
||||
void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
|
||||
const std::vector<Target>& targets, float dt)
|
||||
{
|
||||
// Скорость — не тумблер «шаг/рывок», а непрерывная величина от натяжения.
|
||||
const float baseSpeed = MoveSpeed();
|
||||
|
||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||
{
|
||||
Agent& a = agents[i];
|
||||
a.prevPos = a.pos;
|
||||
|
||||
const Vec2 goal = GoalFor(i);
|
||||
const bool lunging = a.IsMelee() && a.melee.state == MeleeState::STRIKE;
|
||||
|
||||
// --- рывок клинка --------------------------------------------------
|
||||
// В активном окне удара ось уже зафиксирована, и боец едет по ней.
|
||||
// Seek/separation/расступание тут молчат: удар — это обязательство,
|
||||
// а не предложение. Стены остаются: сквозь них не рвётся никто.
|
||||
if (lunging)
|
||||
{
|
||||
const float speed = g_tune.meleeLungeSpeed;
|
||||
const Vec2 dir = FromAngle(a.melee.dir);
|
||||
Vec2 desired = dir * speed;
|
||||
desired += WallAvoidForce(a, map, dir, speed) * g_tune.wWallAvoid;
|
||||
IntegrateAgent(a, desired, speed, dt, map, g_tune.meleeLungeAccel);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- обычные слои, порядок 6.7 --------------------------------------
|
||||
const float maxSpeed = baseSpeed * a.SpeedScale();
|
||||
|
||||
Vec2 desired = SeekForce(a, goal, maxSpeed) * g_tune.wSeek;
|
||||
desired += SeparationForce(a, agents.data(), NUM_AGENTS, maxSpeed) * g_tune.wSeparation;
|
||||
// Замах — это обязательство. Выдёргивать бойца из чужой линии огня
|
||||
// посреди него бессмысленно: стрелок всё равно не выстрелит (правило
|
||||
// 6.3 запрещает), а удар сорвётся. Расступаться боец будет до и после.
|
||||
if (lanes && !(a.IsMelee() && a.melee.Committed()))
|
||||
desired += lanes->AvoidanceForce(i, a.pos, a.bodyRadius, maxSpeed) * g_tune.wLaneAvoid;
|
||||
desired += TargetPushForce(a, targets, maxSpeed) * g_tune.wTargetPush;
|
||||
desired += WallAvoidForce(a, map, Normalized(goal - a.pos), maxSpeed) * g_tune.wWallAvoid;
|
||||
|
||||
IntegrateAgent(a, desired, maxSpeed, dt, map);
|
||||
}
|
||||
}
|
||||
93
src/sim/squad.h
Normal file
93
src/sim/squad.h
Normal file
@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Отряд: якорь, строй, состояния (спек 6.2), сборка слоёв движения агентов.
|
||||
//
|
||||
// Якорь («внутренний курсор») — не телепорт и не переключатель. У него своя
|
||||
// скорость, свой разгон и упругий поводок до центра масс отряда. Чем сильнее
|
||||
// игрок тянет курсор от отряда, тем выше НАТЯЖЕНИЕ (urgency): отряд ускоряется,
|
||||
// строй растягивается вдоль движения, а на самом верху шкалы бойцы просто
|
||||
// бегут и не стреляют вовсе. Подробности — docs/05-squad-ai.md.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <vector>
|
||||
|
||||
#include "ecs/ecs.h"
|
||||
#include "sim/agent.h"
|
||||
#include "sim/target.h"
|
||||
#include "world/tilemap.h"
|
||||
|
||||
struct LaneRegistry; // ai/lane_registry.h — расступание из чужих линий (6.6)
|
||||
|
||||
enum class Formation : uint8_t
|
||||
{
|
||||
WEDGE = 0, // клин
|
||||
LINE = 1, // линия
|
||||
RING = 2, // кольцо
|
||||
COUNT = 3,
|
||||
};
|
||||
|
||||
const char* FormationName(Formation f);
|
||||
|
||||
// Окно в пул компонентов Agent: снаружи это по-прежнему «массив агентов».
|
||||
// Пул плотный, поэтому data() — обычный сплошной Agent*, и ai/ про ECS не знает.
|
||||
struct AgentSpan
|
||||
{
|
||||
Agent* p = nullptr;
|
||||
int n = 0;
|
||||
|
||||
Agent& operator[](int i) { return p[i]; }
|
||||
const Agent& operator[](int i) const { return p[i]; }
|
||||
Agent* data() { return p; }
|
||||
const Agent* data() const { return p; }
|
||||
int size() const { return n; }
|
||||
};
|
||||
|
||||
struct Squad
|
||||
{
|
||||
AgentSpan agents;
|
||||
|
||||
Vec2 anchor{};
|
||||
Vec2 anchorPrev{};
|
||||
Vec2 anchorVel{}; // у курсора есть инерция, а не только позиция
|
||||
Vec2 forward{1.0f, 0.0f}; // ориентация строя = направление движения
|
||||
AgentState state = AgentState::HOLDING;
|
||||
float idleTimer = 0.0f; // сколько нет ввода
|
||||
float holdElapsed = 0.0f; // сколько уже в HOLDING (для SETTLE TIME)
|
||||
Formation formation = Formation::WEDGE;
|
||||
|
||||
// --- натяжение поводка ---------------------------------------------------
|
||||
// 0 — курсор рядом, отряд идёт шагом и работает по целям.
|
||||
// 1 — курсор упёрся в поводок, отряд бежит и огня не ведёт.
|
||||
float urgency = 0.0f;
|
||||
bool sprinting = false; // urgency выше порога: бег без атаки
|
||||
|
||||
// Текущее состояние строя. Меняется плавно: строй ДЫШИТ, а не щёлкает
|
||||
// между двумя таблицами координат.
|
||||
float formSpacing = 1.0f; // общий множитель интервала
|
||||
float formStretch = 1.0f; // вытяжение вдоль направления движения
|
||||
|
||||
// Сколько первых слотов держат клинок. Ставится ДО Init.
|
||||
int meleeCount = DEFAULT_MELEE_COUNT;
|
||||
// Сколько ПОСЛЕДНИХ слотов носят пистолет вместо карабина. Ставится ДО Init.
|
||||
int pistolCount = DEFAULT_PISTOL_COUNT;
|
||||
|
||||
void Init(ecs::World& world, Vec2 at);
|
||||
|
||||
Vec2 SlotLocal(int i) const; // локальные координаты слота (x = вперёд)
|
||||
Vec2 SlotWorld(int i) const; // слот в мире
|
||||
Vec2 GoalFor(int i) const; // куда агент реально идёт
|
||||
Vec2 Centroid() const; // центр масс отряда — к нему привязан поводок
|
||||
|
||||
int RangedCount() const { return NUM_AGENTS - meleeCount; }
|
||||
|
||||
// Текущая скорость пешего хода отряда с учётом натяжения.
|
||||
float MoveSpeed() const { return Lerpf(g_tune.moveSpeed, g_tune.moveSpeedFast, urgency); }
|
||||
|
||||
// Движение якоря (в МИРОВЫХ осях — перевод из экранных делает вызывающий),
|
||||
// натяжение поводка, дыхание строя и определение ADVANCING/HOLDING.
|
||||
void UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap& map, float dt);
|
||||
|
||||
// Слои движения агентов: seek -> separation -> laneAvoidance -> targetPush
|
||||
// -> wallAvoidance. Рывок клинка подменяет seek целиком.
|
||||
void UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
|
||||
const std::vector<Target>& targets, float dt);
|
||||
};
|
||||
96
src/sim/target.cpp
Normal file
96
src/sim/target.cpp
Normal file
@ -0,0 +1,96 @@
|
||||
#include "sim/target.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Кучки: 5 на арене (рядом со стартом), 3 в северной комнате,
|
||||
// 3 в западной, 1 одиночная в восточной. Всего 12.
|
||||
//
|
||||
// Северная кучка стоит НАПРОТИВ прохода x=30..31: из коридора до неё есть
|
||||
// чистая линия. Это тот самый тест-кейс из спека — в узком проходе правило
|
||||
// «не стрелять сквозь своих» упирается в геометрию, и отряд обязан вытянуться
|
||||
// в колонну: стреляет передний, задние молчат.
|
||||
//
|
||||
// Кучка обязана быть В ПРЕДЕЛАХ ДАЛЬНОСТИ от середины прохода (y≈14), иначе
|
||||
// стрелять из коридора не по чему и тест-кейс проверяет пустоту. При weaponRange
|
||||
// 7.2 ближняя мишень стоит в ~6.5 юнита: запас есть, но небольшой — если менять
|
||||
// дальность, эти координаты надо пересчитывать вместе с ней.
|
||||
const float kTargetSpots[NUM_TARGETS][2] = {
|
||||
{30.5f, 20.5f}, {31.5f, 22.5f}, {29.5f, 23.5f}, {32.5f, 21.5f}, {30.5f, 24.5f},
|
||||
{29.5f, 8.5f}, {31.5f, 7.5f}, {27.5f, 10.5f},
|
||||
{ 7.5f, 40.5f}, { 9.5f, 42.5f}, { 6.5f, 43.5f},
|
||||
{43.5f, 35.5f},
|
||||
};
|
||||
|
||||
// Сопротивление отбросу по слотам. Не случайное: в каждой кучке есть кто-то
|
||||
// тяжёлый, кого клинком не сдвинуть, и кто-то лёгкий, кто улетает от первого
|
||||
// же удара. Иначе «отброс» и «сопротивление» — два числа, которых не видно.
|
||||
const float kTargetKnockResist[NUM_TARGETS] = {
|
||||
0.00f, 0.65f, 0.15f, 0.40f, 0.00f,
|
||||
0.55f, 0.10f, 0.30f,
|
||||
0.20f, 0.70f, 0.05f,
|
||||
0.85f,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
void SpawnTargets(ecs::World& world)
|
||||
{
|
||||
for (int i = 0; i < NUM_TARGETS; ++i)
|
||||
{
|
||||
Target t;
|
||||
t.self = world.Create();
|
||||
t.pos = {kTargetSpots[i][0], kTargetSpots[i][1]};
|
||||
t.home = t.pos;
|
||||
t.vel = {};
|
||||
t.hp = g_tune.targetHP;
|
||||
t.maxHp = g_tune.targetHP;
|
||||
t.radius = g_tune.targetRadius;
|
||||
t.respawnTimer = 0.0f;
|
||||
t.alive = true;
|
||||
t.knockResist = kTargetKnockResist[i];
|
||||
world.Add<Target>(t.self, t);
|
||||
}
|
||||
}
|
||||
|
||||
void KnockTarget(Target& t, Vec2 dir, float speed)
|
||||
{
|
||||
t.vel += dir * (speed * t.KnockScale());
|
||||
}
|
||||
|
||||
void UpdateTargets(std::vector<Target>& targets, float dt)
|
||||
{
|
||||
// Отдача гасится и мишень возвращается на своё место: расстановка уровня
|
||||
// захардкожена, и «уползание» мишеней ломало бы все замеры разом.
|
||||
const float damp = std::exp(-g_tune.targetKnockDamp * dt);
|
||||
const float pull = 1.0f - std::exp(-g_tune.targetReturnRate * dt);
|
||||
|
||||
for (Target& t : targets)
|
||||
{
|
||||
t.radius = g_tune.targetRadius;
|
||||
t.maxHp = g_tune.targetHP;
|
||||
|
||||
t.pos += t.vel * dt;
|
||||
t.vel *= damp;
|
||||
t.pos += (t.home - t.pos) * pull;
|
||||
|
||||
if (t.alive) continue;
|
||||
|
||||
t.respawnTimer -= dt;
|
||||
if (t.respawnTimer <= 0.0f)
|
||||
{
|
||||
t.alive = true;
|
||||
t.hp = g_tune.targetHP;
|
||||
t.pos = t.home;
|
||||
t.vel = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int CountAliveTargets(const std::vector<Target>& targets)
|
||||
{
|
||||
int n = 0;
|
||||
for (const Target& t : targets)
|
||||
if (t.alive) ++n;
|
||||
return n;
|
||||
}
|
||||
52
src/sim/target.h
Normal file
52
src/sim/target.h
Normal file
@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Мишени: статичные, не атакуют, респавнятся на том же месте.
|
||||
// 12 штук кучками (5 / 3 / 3 / 1) — расстановка захардкожена.
|
||||
//
|
||||
// Target — компонент ECS: сущности мишеней создаются в SpawnTargets, а вектор,
|
||||
// по которому ходят правила, — это плотный массив пула (см. ecs/ecs.h).
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <vector>
|
||||
|
||||
#include "core/math.h"
|
||||
#include "ecs/ecs.h"
|
||||
#include "tuning.h"
|
||||
|
||||
struct Target
|
||||
{
|
||||
ecs::Entity self = ecs::NO_ENTITY;
|
||||
|
||||
Vec2 pos{};
|
||||
// Своё место. Удар отбрасывает мишень, после чего она возвращается сюда:
|
||||
// без отдачи попадание в упор ничем не отличается от промаха.
|
||||
Vec2 home{};
|
||||
Vec2 vel{};
|
||||
float hp = 0.0f;
|
||||
float maxHp = 0.0f;
|
||||
float respawnTimer = 0.0f;
|
||||
float radius = 0.34f;
|
||||
bool alive = true;
|
||||
|
||||
// Сопротивление отбросу, 0..1. 0 — мишень летит от удара как есть,
|
||||
// 1 — не сдвигается вообще. Тяжёлая цель гасит импульс клинка, и это
|
||||
// единственное, чем она отличается от лёгкой при равном HP.
|
||||
float knockResist = 0.0f;
|
||||
|
||||
// Во сколько раз ослабляется импульс с учётом общего множителя из tuning.
|
||||
float KnockScale() const
|
||||
{
|
||||
const float r = Clampf(knockResist * g_tune.knockResistScale, 0.0f, 0.98f);
|
||||
return 1.0f - r;
|
||||
}
|
||||
};
|
||||
|
||||
// Импульс от удара (dir нормализован вызывающим). speed — импульс ДО того,
|
||||
// как мишень ему сопротивляется: сопротивление применяется здесь, в одном месте,
|
||||
// иначе половина источников урона про него забудет.
|
||||
void KnockTarget(Target& t, Vec2 dir, float speed);
|
||||
|
||||
void SpawnTargets(ecs::World& world);
|
||||
void UpdateTargets(std::vector<Target>& targets, float dt);
|
||||
|
||||
// Число живых мишеней (знаменатель в оценке покрытия, спек 6.5).
|
||||
int CountAliveTargets(const std::vector<Target>& targets);
|
||||
207
src/sim/weapon_defs.h
Normal file
207
src/sim/weapon_defs.h
Normal file
@ -0,0 +1,207 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Каталог оружия: ЧТО такое ствол или клинок, в отрыве от того, кто его держит.
|
||||
//
|
||||
// Оружие — отдельная сущность ECS с компонентом WeaponItem. Боец хранит только
|
||||
// ссылку на неё (Agent::weaponEntity) и рабочее состояние выстрела (патроны,
|
||||
// откат, увод ствола). Поэтому «дать бойцу пистолет» — это подменить компонент
|
||||
// на его сущности-оружии, а не править ни агента, ни правила боя.
|
||||
//
|
||||
// Где живут числа. Общее правило прототипа — все числа в tuning.h. Здесь оно
|
||||
// соблюдено с одной оговоркой, записанной в docs/08-decisions.md: КАРАБИН
|
||||
// (оружие по умолчанию, то самое, на котором меряются критерии приёмки) читает
|
||||
// свои параметры из g_tune, поэтому все прежние ручки оверлея и --set работают
|
||||
// как работали. Остальные стволы — записи каталога: у второго ствола не может
|
||||
// быть «того же самого» weaponRange, иначе он не второй ствол.
|
||||
//
|
||||
// Визуальные параметры атаки лежат ЗДЕСЬ ЖЕ, но в нейтральном виде: длины,
|
||||
// радиусы в пикселях и байты RGB. Ни одного типа рендера в sim/ не появилось —
|
||||
// цвет собирает render/fx.cpp. Так у пистолета своя вспышка и свой трассер,
|
||||
// и при этом правило «sim не знает про рендер» не нарушено.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/math.h"
|
||||
#include "tuning.h"
|
||||
|
||||
enum class WeaponClass : uint8_t
|
||||
{
|
||||
RANGED = 0, // ствол: линия огня, снаряд, правило 6.3
|
||||
MELEE = 1, // клинок: сектор замаха, то же правило в угловой форме
|
||||
};
|
||||
|
||||
inline const char* WeaponClassName(WeaponClass c)
|
||||
{
|
||||
return (c == WeaponClass::MELEE) ? "MELEE" : "RANGED";
|
||||
}
|
||||
|
||||
enum class FirearmId : uint8_t
|
||||
{
|
||||
RIFLE = 0, // штатный карабин отряда: числа из tuning.h
|
||||
PISTOL = 1, // короткоствол: ближе, быстрее, мельче магазин
|
||||
COUNT = 2,
|
||||
};
|
||||
|
||||
// --- ствол -------------------------------------------------------------------
|
||||
|
||||
struct FirearmDef
|
||||
{
|
||||
const char* name = "rifle";
|
||||
|
||||
// баллистика и урон
|
||||
float range = 7.2f;
|
||||
float fireInterval = 0.35f;
|
||||
float damage = 12.0f;
|
||||
float laneHalfWidth = 0.10f;
|
||||
float muzzleOffset = 0.30f;
|
||||
float bulletSpeed = 22.0f;
|
||||
float bulletRadius = 0.04f;
|
||||
float knockback = 0.35f; // импульс мишени от попадания
|
||||
|
||||
// разброс: стоя, на полном ходу, и увод ствола за выстрел
|
||||
float spreadStandDeg = 0.9f;
|
||||
float spreadMoveDeg = 4.2f;
|
||||
float spreadRecoilDeg = 0.55f;
|
||||
float spreadRecoilMax = 3.0f;
|
||||
float recoilDecay = 3.2f; // 1/сек
|
||||
|
||||
// перезарядка
|
||||
int magazine = 24;
|
||||
float reloadTime = 1.9f;
|
||||
|
||||
// визуал атаки (нейтральные числа: пиксели, секунды, байты RGB)
|
||||
float tracerLength = 1.30f;
|
||||
float muzzleRadiusPx = 5.0f;
|
||||
float muzzleTime = 0.07f;
|
||||
float muzzleFlamePx = 0.35f; // длина языка пламени, юнитов мира
|
||||
float flashPower = 0.55f; // как источник света
|
||||
float flashRadius = 4.0f;
|
||||
float shakePerShot = 0.55f;
|
||||
uint8_t tracerR = 255, tracerG = 240, tracerB = 205;
|
||||
uint8_t muzzleR = 255, muzzleG = 225, muzzleB = 150;
|
||||
};
|
||||
|
||||
// --- клинок ------------------------------------------------------------------
|
||||
|
||||
struct BladeDef
|
||||
{
|
||||
const char* name = "blade";
|
||||
|
||||
float reach = 1.40f;
|
||||
float arcDeg = 100.0f;
|
||||
float windup = 0.17f;
|
||||
float strike = 0.13f;
|
||||
float recover = 0.24f;
|
||||
float interval = 0.60f;
|
||||
float damage = 130.0f;
|
||||
float knockback = 2.4f; // импульс мишени, юнитов/сек
|
||||
float lungeSpeed = 3.4f; // рывок в момент удара
|
||||
};
|
||||
|
||||
// --- компонент сущности-оружия ------------------------------------------------
|
||||
|
||||
struct WeaponItem
|
||||
{
|
||||
WeaponClass cls = WeaponClass::RANGED;
|
||||
FirearmId gunId = FirearmId::RIFLE;
|
||||
FirearmDef gun{};
|
||||
BladeDef blade{};
|
||||
};
|
||||
|
||||
// --- каталог ------------------------------------------------------------------
|
||||
|
||||
// Карабин: параметры берутся из tuning, чтобы прежние ручки и --set остались
|
||||
// рабочими (см. шапку файла).
|
||||
inline FirearmDef RifleDef()
|
||||
{
|
||||
FirearmDef d;
|
||||
d.name = "rifle";
|
||||
d.range = g_tune.weaponRange;
|
||||
d.fireInterval = g_tune.fireInterval;
|
||||
d.damage = g_tune.damage;
|
||||
d.laneHalfWidth = g_tune.laneHalfWidth;
|
||||
d.muzzleOffset = g_tune.muzzleOffset;
|
||||
d.bulletSpeed = g_tune.bulletSpeed;
|
||||
d.bulletRadius = g_tune.bulletRadius;
|
||||
d.knockback = g_tune.bulletKnock;
|
||||
|
||||
d.spreadStandDeg = g_tune.spreadStandDeg;
|
||||
d.spreadMoveDeg = g_tune.spreadMoveDeg;
|
||||
d.spreadRecoilDeg = g_tune.spreadRecoilDeg;
|
||||
d.spreadRecoilMax = g_tune.spreadRecoilMax;
|
||||
d.recoilDecay = g_tune.spreadRecoilDecay;
|
||||
|
||||
d.magazine = int(g_tune.magazineRifle);
|
||||
d.reloadTime = g_tune.reloadRifle;
|
||||
|
||||
d.tracerLength = g_tune.tracerLength;
|
||||
d.muzzleRadiusPx = g_tune.muzzleRadiusPx;
|
||||
d.muzzleTime = g_tune.muzzleTime;
|
||||
d.flashPower = g_tune.flashPower;
|
||||
d.flashRadius = g_tune.flashRadius;
|
||||
d.shakePerShot = g_tune.shakePerShot;
|
||||
return d;
|
||||
}
|
||||
|
||||
// Пистолет: бьёт ближе и слабее, но чаще; на ходу «гуляет» заметно сильнее
|
||||
// карабина, зато перезаряжается вдвое быстрее. Вспышка мельче и холоднее,
|
||||
// трассер короче — ствол должен читаться по картинке, а не только по цифрам.
|
||||
inline FirearmDef PistolDef()
|
||||
{
|
||||
FirearmDef d;
|
||||
d.name = "pistol";
|
||||
d.range = 4.6f;
|
||||
d.fireInterval = 0.22f;
|
||||
d.damage = 8.0f;
|
||||
d.laneHalfWidth = 0.10f;
|
||||
d.muzzleOffset = 0.24f;
|
||||
d.bulletSpeed = 17.0f;
|
||||
d.bulletRadius = 0.04f;
|
||||
d.knockback = 0.22f;
|
||||
|
||||
d.spreadStandDeg = 1.6f;
|
||||
d.spreadMoveDeg = 6.5f;
|
||||
d.spreadRecoilDeg = 0.9f;
|
||||
d.spreadRecoilMax = 4.5f;
|
||||
d.recoilDecay = 4.5f;
|
||||
|
||||
d.magazine = 7;
|
||||
d.reloadTime = 1.15f;
|
||||
|
||||
d.tracerLength = 0.85f;
|
||||
d.muzzleRadiusPx = 3.5f;
|
||||
d.muzzleTime = 0.055f;
|
||||
d.muzzleFlamePx = 0.26f;
|
||||
d.flashPower = 0.42f;
|
||||
d.flashRadius = 3.2f;
|
||||
d.shakePerShot = 0.34f;
|
||||
d.tracerR = 255; d.tracerG = 214; d.tracerB = 170;
|
||||
d.muzzleR = 255; d.muzzleG = 205; d.muzzleB = 130;
|
||||
return d;
|
||||
}
|
||||
|
||||
inline FirearmDef FirearmDefOf(FirearmId id)
|
||||
{
|
||||
return (id == FirearmId::PISTOL) ? PistolDef() : RifleDef();
|
||||
}
|
||||
|
||||
inline const char* FirearmName(FirearmId id)
|
||||
{
|
||||
return (id == FirearmId::PISTOL) ? "PISTOL" : "RIFLE";
|
||||
}
|
||||
|
||||
// Клинок пока один, и его числа целиком в tuning.
|
||||
inline BladeDef DefaultBladeDef()
|
||||
{
|
||||
BladeDef d;
|
||||
d.reach = g_tune.meleeReach;
|
||||
d.arcDeg = g_tune.meleeArcDeg;
|
||||
d.windup = g_tune.meleeWindup;
|
||||
d.strike = g_tune.meleeStrike;
|
||||
d.recover = g_tune.meleeRecover;
|
||||
d.interval = g_tune.meleeInterval;
|
||||
d.damage = g_tune.meleeDamage;
|
||||
d.knockback = g_tune.meleeKnockback;
|
||||
d.lungeSpeed = g_tune.meleeLungeSpeed;
|
||||
return d;
|
||||
}
|
||||
459
src/tuning.h
Normal file
459
src/tuning.h
Normal file
@ -0,0 +1,459 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
// -----------------------------------------------------------------------------
|
||||
// Единственное место, где живут числа прототипа.
|
||||
// В коде не должно быть магических констант — всё берётся отсюда.
|
||||
//
|
||||
// * блок COMPILE-TIME — размеры буферов/карты, менять только пересборкой;
|
||||
// * struct Tuning — всё, что подкручивается в рантайме ([ ] выбор, - = шаг).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// --- compile-time константы --------------------------------------------------
|
||||
|
||||
constexpr int INTERNAL_W = 480; // внутреннее разрешение фреймбуфера
|
||||
constexpr int INTERNAL_H = 270;
|
||||
constexpr int WINDOW_SCALE = 3; // целочисленный апскейл
|
||||
constexpr int WINDOW_W = INTERNAL_W * WINDOW_SCALE;
|
||||
constexpr int WINDOW_H = INTERNAL_H * WINDOW_SCALE;
|
||||
|
||||
constexpr int TILE_W = 32; // ромб пола
|
||||
constexpr int TILE_H = 16;
|
||||
constexpr int TILE_HALF_W = TILE_W / 2;
|
||||
constexpr int TILE_HALF_H = TILE_H / 2;
|
||||
constexpr int WALL_H = 32; // спрайт стены 32x32 (верхний ромб + грани)
|
||||
|
||||
constexpr float FIXED_DT = 1.0f / 60.0f; // шаг симуляции
|
||||
constexpr int MAX_STEPS_PER_FRAME = 5; // защита от спирали смерти
|
||||
|
||||
constexpr int MAP_W = 48;
|
||||
constexpr int MAP_H = 48;
|
||||
|
||||
// Точка старта отряда (центральная арена).
|
||||
constexpr float SQUAD_START_X = 26.5f; // ближе к кучке: дальность стала 7.2
|
||||
constexpr float SQUAD_START_Y = 26.5f;
|
||||
|
||||
constexpr int NUM_AGENTS = 5;
|
||||
constexpr int NUM_TARGETS = 12;
|
||||
constexpr int MAX_BULLETS = 512;
|
||||
|
||||
// Сколько слотов строя занимают бойцы ближнего боя (слоты 0..N-1).
|
||||
// В клине это остриё и левое крыло — те, кто и так идёт первым.
|
||||
constexpr int DEFAULT_MELEE_COUNT = 2;
|
||||
|
||||
// Сколько ПОСЛЕДНИХ слотов носят пистолет вместо карабина.
|
||||
// Короткий ствол уезжает в хвост клина: ему там и место.
|
||||
constexpr int DEFAULT_PISTOL_COUNT = 1;
|
||||
|
||||
// Сколько тайлов пола могут нести запечённую кровь. Каждый — своя копия
|
||||
// спрайта 32x16 (2 КБ), то есть потолок ~2.4 МБ. Дальше новые пятна ложатся
|
||||
// только на уже запачканные тайлы: декали вечные, но не бесконечные.
|
||||
constexpr int BLOOD_TILE_BUDGET = 1200;
|
||||
|
||||
// Решатель огневых позиций: 3 кольца x 12 направлений + текущая позиция.
|
||||
constexpr int SOLVER_RINGS = 3;
|
||||
constexpr int SOLVER_DIRS = 12;
|
||||
constexpr int SOLVER_CANDIDATES = SOLVER_RINGS * SOLVER_DIRS + 1;
|
||||
|
||||
// --- runtime-тюнинг ----------------------------------------------------------
|
||||
|
||||
struct Tuning
|
||||
{
|
||||
// движение агента
|
||||
float moveSpeed = 1.75f; // юнитов/сек: хоррор, а не шутер-беготня
|
||||
float moveSpeedFast = 3.6f; // догоняют курсор быстро — иначе отряд отстаёт
|
||||
float steerAccel = 18.0f; // тяжелее; НИЖЕ 16 нельзя — ломается критерий 11.4
|
||||
float bodyRadius = 0.28f;
|
||||
float separationRadius = 0.7f;
|
||||
float wSeek = 1.0f;
|
||||
float wSeparation = 1.0f;
|
||||
float wLaneAvoid = 2.4f; // строго выше wSeparation (спек 6.7)
|
||||
float wWallAvoid = 1.2f;
|
||||
float wallLookahead = 0.8f;
|
||||
float wallSlide = 1.4f; // касательная составляющая обхода стен
|
||||
float arriveRadius = 0.18f;
|
||||
float turnRateDeg = 300.0f; // ствол тяжёлый
|
||||
float wTargetPush = 1.0f; // мишень — тело, а не пустое место
|
||||
float targetPushGap = 0.10f; // зазор поверх суммы радиусов
|
||||
|
||||
// оружие (это ПАРАМЕТРЫ КАРАБИНА — оружия по умолчанию, на котором меряются
|
||||
// критерии приёмки. Остальные стволы — записи каталога sim/weapon_defs.h)
|
||||
float weaponRange = 7.2f; // -20% от исходных 9.0
|
||||
float fireInterval = 0.35f;
|
||||
float laneHalfWidth = 0.10f;
|
||||
float damage = 12.0f;
|
||||
float safetyMargin = 0.15f;
|
||||
float muzzleOffset = 0.3f;
|
||||
float bulletSpeed = 22.0f;
|
||||
float bulletRadius = 0.04f;
|
||||
float bulletKnock = 0.35f; // импульс мишени от попадания пули
|
||||
float aimToleranceDeg = 5.0f; // ствол должен быть доведён до цели
|
||||
|
||||
// --- разброс -------------------------------------------------------------
|
||||
// Стоя ствол почти не гуляет, на полном ходу гуляет сильно, очередь его
|
||||
// раскрывает. Тот же конус проверяется на своих перед спуском: правило 6.3
|
||||
// не ослаблено, оно просто считает по конусу вместо отрезка.
|
||||
float spreadStandDeg = 0.9f;
|
||||
float spreadMoveDeg = 4.2f;
|
||||
float spreadRecoilDeg = 0.55f; // прибавка за выстрел
|
||||
float spreadRecoilMax = 3.0f;
|
||||
float spreadRecoilDecay = 3.2f; // градусов/сек оседания
|
||||
float spreadScale = 1.0f; // общий множитель, ручка для замеров
|
||||
|
||||
// --- перезарядка ---------------------------------------------------------
|
||||
float magazineRifle = 24.0f; // патронов в магазине карабина
|
||||
float reloadRifle = 1.9f; // сек
|
||||
// Запас на манёвр союзника за время полёта пули (доля от moveSpeed).
|
||||
// 0 = верить экстраполяции скорости буквально.
|
||||
float shotPredictSlack = 0.3f;
|
||||
|
||||
// --- ближний бой ---------------------------------------------------------
|
||||
// Три фазы: замах видно, урон живёт в коротком окне, после удара боец открыт.
|
||||
float meleeReach = 1.40f; // от центра тела до конца замаха
|
||||
float meleeArcDeg = 100.0f; // ширина сектора удара
|
||||
float meleeWindup = 0.17f; // замах — телеграф
|
||||
float meleeStrike = 0.13f; // активное окно урона
|
||||
float meleeRecover = 0.24f; // после удара
|
||||
float meleeInterval = 0.60f; // между началами замахов
|
||||
float meleeDamage = 130.0f; // 4 удара на мишень против 42 пуль
|
||||
float meleeBladeDeg = 34.0f; // ширина полосы самого лезвия
|
||||
float meleeAimToleranceDeg = 30.0f; // допуск разворота на старте замаха
|
||||
float meleeAllyMargin = 0.14f; // запас к телу союзника в секторе
|
||||
// На сколько взмахов вперёд смотреть на союзника при старте замаха
|
||||
// (в долях windup+strike). 0 = верить только текущему положению.
|
||||
float meleeStartPredict = 1.0f;
|
||||
// Доля бокового размаха сектора, которую клинок резервирует в реестре линий.
|
||||
// 1.0 = вся дуга; тогда своим нечем дышать. 0 = резерв шириной с отрезок.
|
||||
float meleeReserveFrac = 0.55f;
|
||||
float meleeGap = 0.12f; // зазор при подходе вплотную
|
||||
float meleeLungeSpeed = 3.4f; // рывок в момент удара, юнитов/сек
|
||||
float meleeLungeAccel = 120.0f; // рывок обязан успеть за 0.13 с
|
||||
float meleeWindupScale = 0.20f; // множитель скорости на замахе
|
||||
float meleeRecoverScale= 0.55f;
|
||||
float meleeKnockback = 2.4f; // импульс мишени, юнитов/сек
|
||||
// СВОЙ круг бойца: его отпускают от диска дальше, чем стрелка.
|
||||
float meleeCohesionRadius = 5.6f;
|
||||
float meleeSeekRadius = 7.0f; // дальше этого боец за целью не идёт вообще
|
||||
// Штраф за уже занятую цель, в юнитах дистанции. Двое с клинками, вцепившиеся
|
||||
// в одного, половину взмахов обрывают друг об друга — пусть расходятся.
|
||||
float meleeClaimPenalty = 3.0f;
|
||||
// Сколько держится связка после удара. Не успел продолжить — начинаешь
|
||||
// серию заново с первого удара.
|
||||
float meleeComboWindow = 1.10f;
|
||||
|
||||
// мишени
|
||||
float targetHP = 500.0f;
|
||||
float targetRespawn = 3.0f;
|
||||
float targetRadius = 0.34f;
|
||||
float targetKnockDamp = 9.0f; // гашение импульса от удара
|
||||
float targetReturnRate= 5.0f; // возврат на своё место
|
||||
// Общий множитель сопротивления отбросу. Само сопротивление у каждой мишени
|
||||
// своё (sim/target.cpp): тяжёлую клинком не сдвинуть, лёгкая улетает.
|
||||
float knockResistScale = 1.0f;
|
||||
|
||||
// отряд
|
||||
float holdDelay = 0.22f; // сек без ввода -> HOLDING
|
||||
float cohesionRadius = 2.1f; // стрелки держатся кучнее
|
||||
float minEngageDist = 1.6f; // отмасштабировано под дальность 7.2
|
||||
float formationSpacing = 1.0f; // строй плотнее
|
||||
float cameraSmooth = 9.0f;
|
||||
float anchorRadius = 0.3f; // якорь тоже скользит вдоль стен
|
||||
// Диск отзывчивее бойцов: команда читается мгновенно, а догоняют её ногами.
|
||||
float anchorSpeed = 3.3f;
|
||||
float anchorSpeedFast = 4.8f;
|
||||
float anchorAccel = 20.0f; // разгон/торможение курсора, юнитов/сек^2
|
||||
// Поводок УПРУГИЙ, а не жёсткий: без него быстрый диск просто отрывается от
|
||||
// отряда и теряет смысл, но жёсткий упор намертво фиксирует дистанцию, и по
|
||||
// ней уже не отличить «подправил направление» от «тяну изо всех сил».
|
||||
// anchorLeash — мягкая граница: до неё курсор идёт свободно;
|
||||
// anchorLeashMax — жёсткий предел: страховка, до неё обычно не доходит;
|
||||
// leashDrag — насколько тяжелеет курсор между границами (0..1).
|
||||
float anchorLeash = 3.0f;
|
||||
float anchorLeashMax = 5.0f;
|
||||
float leashDrag = 0.75f; // на жёсткой границе остаётся 25% скорости
|
||||
|
||||
// --- натяжение поводка (см. docs/05-squad-ai.md) --------------------------
|
||||
// Курсор упёрся в поводок, а игрок продолжает давить -> он хочет идти
|
||||
// быстрее, чем отряд шагает. Это и есть команда «бегом»: отряд ускоряется,
|
||||
// строй растягивается, а на верхней трети шкалы огонь прекращается вовсе.
|
||||
float urgeStartFrac = 0.50f; // с какой доли поводка начинается натяжение
|
||||
float urgeAttack = 2.6f; // как быстро натяжение набирается
|
||||
float urgeRelease = 3.4f; // и как быстро отпускает
|
||||
float sprintAt = 0.85f; // выше — спринт: бежим, не атакуем.
|
||||
// Порог высокий намеренно: при удержании направления натяжение садится
|
||||
// примерно на 0.54, то есть отряд идёт быстрым шагом И ПРОДОЛЖАЕТ работать
|
||||
// по целям. Спринт остаётся тем, чем должен быть, — отдельным решением
|
||||
// (Shift) или следствием того, что курсор реально уехал.
|
||||
|
||||
// --- дыхание строя --------------------------------------------------------
|
||||
float holdSpacing = 0.90f; // на стоянке отряд собирается
|
||||
float sprintSpacing = 1.30f; // в беге расходится
|
||||
float sprintStretch = 1.55f; // и вытягивается вдоль движения
|
||||
float formMorphRate = 4.0f; // скорость перестроения, 1/сек
|
||||
|
||||
// решатель огневых позиций (6.5)
|
||||
float solveInterval = 0.25f;
|
||||
float commitTime = 0.6f;
|
||||
float hysteresis = 15.0f;
|
||||
float ring1 = 0.7f; // кольца решателя сжаты вслед за строем
|
||||
float ring2 = 1.4f;
|
||||
float ring3 = 2.1f;
|
||||
float candMinAlly = 0.60f; // минимум до другого агента (было МЕНЬШЕ диаметра тела)
|
||||
float sLaneClear = 100.0f;
|
||||
float sCoverage = 25.0f;
|
||||
float sBlockAlly = 35.0f;
|
||||
float sDistCost = 9.0f;
|
||||
float sCohesionCost = 24.0f; // отбегать от отряда дорого
|
||||
float sTooClose = 60.0f;
|
||||
// Жёсткая граница огневой позиции: дальше cohesionRadius + postLeash от диска
|
||||
// кандидат просто отбраковывается. Одной цены мало — линия огня стоит 100
|
||||
// очков, и за неё решатель готов уйти сколько угодно далеко. А отряд, из
|
||||
// которого стрелок ушёл на пять метров, отрядом быть перестаёт.
|
||||
float postLeash = 1.0f;
|
||||
|
||||
// расступание из чужих линий (6.6)
|
||||
float laneAvoidStrength = 7.0f;
|
||||
// Ширина РЕЗЕРВА линии относительно порога «тело в линии» из 6.3.
|
||||
// >1 означает: агента отжимает ДО того, как он влез в чужой сектор.
|
||||
// Правило стрельбы (6.3) от этого не меняется — см. docs/08-decisions.md.
|
||||
float laneReserveScale = 2.0f;
|
||||
|
||||
// ручное прицеливание (6.8)
|
||||
float aimConeDeg = 20.0f;
|
||||
float aimTurnRateDeg = 110.0f; // прицел ведётся тяжелее
|
||||
|
||||
// метрики
|
||||
float uptimeWindow = 10.0f; // окно FIRE UPTIME, сек
|
||||
float settleQuorum = 4.0f; // сколько агентов нужно для SETTLE TIME
|
||||
|
||||
// --- свет и туман войны (render-only, на симуляцию не влияет) -----------
|
||||
float sightRadius = 7.6f; // где обзор гаснет полностью
|
||||
float sightFullRadius = 2.2f; // дальше сразу начинается спад: свет должен таять
|
||||
float sightConeDeg = 100.0f; // конус взгляда агента
|
||||
float sightConeSoftDeg = 22.0f; // мягкий край конуса
|
||||
float sightPersonalRadius= 1.9f; // круговой «личный» ореол вокруг агента
|
||||
float sightBrightness = 0.86f; // яркость видимого; остаток отдан вспышкам
|
||||
float fogMemoryLevel = 0.13f; // как тускло помнится уже увиденное
|
||||
float fogWallBleed = 0.92f; // затухание света вглубь блока стен
|
||||
float fogFadeRate = 9.0f; // сглаживание во времени, чтобы не щёлкало
|
||||
float fogEntityReveal = 0.30f; // ниже этого света сущность не показывается
|
||||
float eyeRevealLight = 0.05f; // глаз мишени тлеет почти в полной темноте
|
||||
float eyeRadiusPx = 3.0f;
|
||||
float eyeLiftPx = 11.0f; // на уровне «головы» капсулы
|
||||
float agentMinLight = 0.34f; // свои читаемы всегда: игрок обязан их различать
|
||||
|
||||
// вспышки выстрелов как источники света
|
||||
float flashPower = 0.55f;
|
||||
float flashRadius = 4.0f;
|
||||
float flashTime = 0.11f;
|
||||
|
||||
// рампа освещения (меняет саму таблицу — требует перепечь)
|
||||
float lightGamma = 1.45f;
|
||||
float lightMaxGain = 1.25f;
|
||||
|
||||
// --- эффекты стрельбы ---------------------------------------------------
|
||||
float fxIntensity = 1.0f;
|
||||
float tracerLength = 1.30f; // длина светящегося штриха, юнитов мира
|
||||
float bulletLiftPx = 8.0f; // пуля летит на уровне груди, не по земле
|
||||
float muzzleTime = 0.07f;
|
||||
float muzzleRadiusPx = 5.0f;
|
||||
float sparkTime = 0.16f;
|
||||
float sparkRadiusPx = 4.0f;
|
||||
float bladeLiftPx = 9.0f; // дуга метёт на уровне груди, не по земле
|
||||
float bladeTrailFrac = 0.80f; // какая доля пройденной дуги ещё светится
|
||||
float meleeShake = 1.4f; // отдача камеры на попадание клинком
|
||||
|
||||
// --- напряжение и постобработка -----------------------------------------
|
||||
float dreadRadius = 7.0f; // с какой дистанции мишень начинает пугать
|
||||
float dreadAttack = 6.0f; // страх набегает быстро
|
||||
float dreadRelease = 0.5f; // отпускает медленно
|
||||
float vigStartCalm = 0.42f; // где начинается виньетка
|
||||
float vigStartTense = 0.20f;
|
||||
float vigDepthCalm = 0.55f; // насколько темнеет край
|
||||
float vigDepthTense = 0.85f;
|
||||
float grainCalm = 6.0f; // амплитуда зерна (макс 127)
|
||||
float grainTense = 16.0f;
|
||||
float heartBpmCalm = 58.0f;
|
||||
float heartBpmTense = 132.0f;
|
||||
float heartAmp = 0.10f; // насколько сердцебиение давит картинку
|
||||
float shotFlash = 0.12f; // подсветка кадра на выстрел
|
||||
float shakePerShot = 0.55f; // отдача камеры, пикселей на выстрел
|
||||
float shakeMax = 3.0f;
|
||||
float shakeDecay = 9.0f;
|
||||
|
||||
// --- кровь (render-only, декали запекаются в спрайты пола) ---------------
|
||||
float bloodAmount = 1.0f; // общий множитель размера пятен, 0 = выключить
|
||||
float bloodRadiusHit = 0.26f; // пуля
|
||||
float bloodRadiusMelee = 0.50f; // клинок
|
||||
float bloodRadiusKill = 0.78f; // смерть: лужа
|
||||
float bloodDrops = 5.0f; // сколько брызг летит по направлению удара
|
||||
float bloodOpacity = 0.92f; // насколько густо пятно перекрывает пол
|
||||
|
||||
// --- дымка и пепел в воздухе (render-only) --------------------------------
|
||||
float hazeAmount = 0.34f; // сила дымки, 0 = выключить
|
||||
float hazeScale = 1.0f; // крупность клубов
|
||||
float hazeDrift = 0.05f; // скорость сноса, экранных ячеек/сек
|
||||
float hazeGate = 24.0f; // ниже этой яркости дымки нет: тьма остаётся тьмой
|
||||
float ashDensity = 0.75f; // пылинок на тайл
|
||||
float ashSpeed = 0.30f; // скорость дрейфа пепла
|
||||
float ashBrightness = 0.60f;
|
||||
float ashLiftPx = 13.0f; // на какой высоте висит взвесь
|
||||
|
||||
// флаги
|
||||
bool freeFireSecondary = true; // 6.8: стрелять по вторичным целям в движении
|
||||
bool friendlyFire = true; // 6.9: всегда true, тумблер только для проверки
|
||||
};
|
||||
|
||||
// --- связка ударов ближнего боя ----------------------------------------------
|
||||
//
|
||||
// Чередование «влево-вправо» — это не связка, а метроном: два удара, между
|
||||
// которыми нет разницы ни на глаз, ни по последствиям. Связка обязана иметь
|
||||
// ФОРМУ: быстрый вход, злой возврат, тяжёлое завершение.
|
||||
//
|
||||
// Множители, а не абсолютные числа: базовые характеристики клинка остаются
|
||||
// одним набором в Tuning и продолжают крутиться на ходу, а связка их гнёт.
|
||||
struct MeleeComboStep
|
||||
{
|
||||
const char* name;
|
||||
float arcMul; // ширина сектора
|
||||
float dmgMul;
|
||||
float knockMul;
|
||||
float windupMul; // замах — телеграф; у тяжёлого удара он длиннее
|
||||
float strikeMul; // активное окно
|
||||
float recoverMul; // и расплата после
|
||||
float intervalMul; // пауза до следующего удара связки
|
||||
bool flipSide; // менять сторону захода лезвия
|
||||
};
|
||||
|
||||
inline const MeleeComboStep MELEE_COMBO[] = {
|
||||
// 1. Косой заход. Обычный удар, с него всё начинается.
|
||||
{"slash", 1.00f, 1.00f, 1.00f, 1.00f, 1.00f, 1.00f, 1.00f, true},
|
||||
// 2. Возврат с обратной стороны. Быстрее и почти без паузы — именно это
|
||||
// читается как «связка», а не как два отдельных удара.
|
||||
{"backhand",0.85f, 1.00f, 0.85f, 0.55f, 0.80f, 0.70f, 0.50f, true},
|
||||
// 3. Раскрутка. Широкая, тяжёлая, с долгим восстановлением: в тесноте её
|
||||
// просто не дадут начать — сектор не будет чист, и это правильная цена.
|
||||
{"spin", 1.70f, 1.75f, 2.30f, 1.50f, 1.40f, 1.70f, 1.35f, false},
|
||||
};
|
||||
|
||||
inline constexpr int MELEE_COMBO_STEPS = int(sizeof(MELEE_COMBO) / sizeof(MELEE_COMBO[0]));
|
||||
|
||||
inline Tuning g_tune;
|
||||
|
||||
// Максимально возможная скорость агента НА ХОДУ. Проверка спуска на время полёта
|
||||
// пули обязана считать по НЕЙ, иначе рывок союзника не учитывается.
|
||||
//
|
||||
// Рывок бойца ближнего боя сюда намеренно не входит: он не «манёвр», а прямой
|
||||
// отрезок с уже известным направлением, и AllyInterceptsShot проверяет его
|
||||
// точно — отдельной экстраполяцией (см. ai/firing_solver.cpp).
|
||||
inline float MaxAgentSpeed() { return std::max(g_tune.moveSpeed, g_tune.moveSpeedFast); }
|
||||
|
||||
// --- таблица параметров для подкрутки в оверлее ------------------------------
|
||||
|
||||
struct TunableParam
|
||||
{
|
||||
const char* name;
|
||||
float Tuning::* member;
|
||||
float step;
|
||||
float minV;
|
||||
float maxV;
|
||||
};
|
||||
|
||||
inline const TunableParam TUNABLES[] = {
|
||||
{"safetyMargin", &Tuning::safetyMargin, 0.01f, 0.0f, 1.0f},
|
||||
{"laneHalfWidth", &Tuning::laneHalfWidth, 0.01f, 0.0f, 0.5f},
|
||||
{"bodyRadius", &Tuning::bodyRadius, 0.01f, 0.1f, 0.8f},
|
||||
{"wLaneAvoid", &Tuning::wLaneAvoid, 0.1f, 0.0f, 10.0f},
|
||||
{"laneAvoidStr", &Tuning::laneAvoidStrength,0.25f, 0.0f, 30.0f},
|
||||
{"laneReserve", &Tuning::laneReserveScale, 0.05f, 1.0f, 4.0f},
|
||||
{"predictSlack", &Tuning::shotPredictSlack, 0.05f, 0.0f, 2.0f},
|
||||
{"wSeparation", &Tuning::wSeparation, 0.1f, 0.0f, 10.0f},
|
||||
{"steerAccel", &Tuning::steerAccel, 1.0f, 2.0f, 60.0f},
|
||||
{"moveSpeed", &Tuning::moveSpeed, 0.1f, 0.5f, 10.0f},
|
||||
{"moveSpeedFast", &Tuning::moveSpeedFast, 0.1f, 0.5f, 12.0f},
|
||||
{"weaponRange", &Tuning::weaponRange, 0.25f, 2.0f, 20.0f},
|
||||
{"fireInterval", &Tuning::fireInterval, 0.01f, 0.05f, 2.0f},
|
||||
{"bulletSpeed", &Tuning::bulletSpeed, 1.0f, 5.0f, 60.0f},
|
||||
{"solveInterval", &Tuning::solveInterval, 0.05f, 0.05f, 2.0f},
|
||||
{"commitTime", &Tuning::commitTime, 0.05f, 0.0f, 3.0f},
|
||||
{"hysteresis", &Tuning::hysteresis, 1.0f, 0.0f, 100.0f},
|
||||
{"cohesionRadius", &Tuning::cohesionRadius, 0.1f, 1.0f, 12.0f},
|
||||
{"minEngageDist", &Tuning::minEngageDist, 0.1f, 0.0f, 8.0f},
|
||||
{"sLaneClear", &Tuning::sLaneClear, 5.0f, 0.0f, 400.0f},
|
||||
{"sCoverage", &Tuning::sCoverage, 1.0f, 0.0f, 200.0f},
|
||||
{"sBlockAlly", &Tuning::sBlockAlly, 1.0f, 0.0f, 200.0f},
|
||||
{"sDistCost", &Tuning::sDistCost, 0.5f, 0.0f, 100.0f},
|
||||
{"sCohesionCost", &Tuning::sCohesionCost, 0.5f, 0.0f, 100.0f},
|
||||
{"sTooClose", &Tuning::sTooClose, 2.0f, 0.0f, 300.0f},
|
||||
{"postLeash", &Tuning::postLeash, 0.1f, 0.0f, 10.0f},
|
||||
{"formSpacing", &Tuning::formationSpacing, 0.05f, 0.4f, 4.0f},
|
||||
{"anchorSpeed", &Tuning::anchorSpeed, 0.1f, 0.5f, 12.0f},
|
||||
{"anchorSpeedFast",&Tuning::anchorSpeedFast, 0.1f, 0.5f, 16.0f},
|
||||
{"anchorLeash", &Tuning::anchorLeash, 0.1f, 0.2f, 12.0f},
|
||||
{"anchorLeashMax", &Tuning::anchorLeashMax, 0.1f, 0.4f, 16.0f},
|
||||
{"leashDrag", &Tuning::leashDrag, 0.05f, 0.0f, 1.0f},
|
||||
{"anchorAccel", &Tuning::anchorAccel, 1.0f, 2.0f, 80.0f},
|
||||
// --- натяжение и строй ---
|
||||
{"urgeStart", &Tuning::urgeStartFrac, 0.05f, 0.0f, 0.95f},
|
||||
{"urgeAttack", &Tuning::urgeAttack, 0.2f, 0.1f, 20.0f},
|
||||
{"urgeRelease", &Tuning::urgeRelease, 0.2f, 0.1f, 20.0f},
|
||||
{"sprintAt", &Tuning::sprintAt, 0.02f, 0.05f, 1.0f},
|
||||
{"holdSpacing", &Tuning::holdSpacing, 0.02f, 0.4f, 2.0f},
|
||||
{"sprintSpacing", &Tuning::sprintSpacing, 0.02f, 0.4f, 3.0f},
|
||||
{"sprintStretch", &Tuning::sprintStretch, 0.05f, 0.5f, 4.0f},
|
||||
{"formMorph", &Tuning::formMorphRate, 0.25f, 0.25f, 20.0f},
|
||||
// --- разброс и перезарядка ---
|
||||
{"spreadStand", &Tuning::spreadStandDeg, 0.1f, 0.0f, 20.0f},
|
||||
{"spreadMove", &Tuning::spreadMoveDeg, 0.1f, 0.0f, 30.0f},
|
||||
{"spreadRecoil", &Tuning::spreadRecoilDeg, 0.05f, 0.0f, 10.0f},
|
||||
{"spreadRecMax", &Tuning::spreadRecoilMax, 0.1f, 0.0f, 20.0f},
|
||||
{"spreadDecay", &Tuning::spreadRecoilDecay,0.2f, 0.1f, 40.0f},
|
||||
{"spreadScale", &Tuning::spreadScale, 0.05f, 0.0f, 4.0f},
|
||||
{"magazine", &Tuning::magazineRifle, 1.0f, 1.0f, 200.0f},
|
||||
{"reloadTime", &Tuning::reloadRifle, 0.05f, 0.1f, 8.0f},
|
||||
{"bulletKnock", &Tuning::bulletKnock, 0.05f, 0.0f, 6.0f},
|
||||
{"knockResist", &Tuning::knockResistScale, 0.05f, 0.0f, 2.0f},
|
||||
// --- ближний бой ---
|
||||
{"meleeReach", &Tuning::meleeReach, 0.05f, 0.4f, 4.0f},
|
||||
{"meleeArc", &Tuning::meleeArcDeg, 5.0f, 20.0f, 300.0f},
|
||||
{"meleeWindup", &Tuning::meleeWindup, 0.01f, 0.02f, 1.0f},
|
||||
{"meleeStrike", &Tuning::meleeStrike, 0.01f, 0.02f, 1.0f},
|
||||
{"meleeRecover", &Tuning::meleeRecover, 0.01f, 0.0f, 1.5f},
|
||||
{"meleeInterval", &Tuning::meleeInterval, 0.02f, 0.1f, 3.0f},
|
||||
{"meleeDamage", &Tuning::meleeDamage, 5.0f, 1.0f, 999.0f},
|
||||
{"meleeLunge", &Tuning::meleeLungeSpeed, 0.1f, 0.0f, 12.0f},
|
||||
{"meleeKnock", &Tuning::meleeKnockback, 0.1f, 0.0f, 12.0f},
|
||||
{"meleeCohesion", &Tuning::meleeCohesionRadius, 0.2f, 1.0f, 20.0f},
|
||||
{"meleeAllyMargin",&Tuning::meleeAllyMargin, 0.02f, 0.0f, 1.0f},
|
||||
{"meleePredict", &Tuning::meleeStartPredict,0.1f, 0.0f, 3.0f},
|
||||
{"meleeReserve", &Tuning::meleeReserveFrac, 0.05f, 0.0f, 1.5f},
|
||||
{"meleeShake", &Tuning::meleeShake, 0.1f, 0.0f, 6.0f},
|
||||
// --- хоррор-часть ---
|
||||
{"sightRadius", &Tuning::sightRadius, 0.25f, 2.0f, 24.0f},
|
||||
{"sightCone", &Tuning::sightConeDeg, 5.0f, 20.0f, 360.0f},
|
||||
{"sightPersonal", &Tuning::sightPersonalRadius, 0.1f, 0.5f, 10.0f},
|
||||
{"fogMemory", &Tuning::fogMemoryLevel, 0.02f, 0.0f, 1.0f},
|
||||
{"fogReveal", &Tuning::fogEntityReveal, 0.02f, 0.0f, 1.0f},
|
||||
{"flashPower", &Tuning::flashPower, 0.05f, 0.0f, 2.0f},
|
||||
{"lightGamma", &Tuning::lightGamma, 0.05f, 0.3f, 4.0f},
|
||||
{"lightGain", &Tuning::lightMaxGain, 0.05f, 0.2f, 3.0f},
|
||||
{"fxIntensity", &Tuning::fxIntensity, 0.1f, 0.0f, 4.0f},
|
||||
{"tracerLength", &Tuning::tracerLength, 0.1f, 0.1f, 6.0f},
|
||||
{"grainTense", &Tuning::grainTense, 1.0f, 0.0f, 100.0f},
|
||||
{"vigDepthTense", &Tuning::vigDepthTense, 0.05f, 0.0f, 1.0f},
|
||||
{"dreadRadius", &Tuning::dreadRadius, 0.5f, 1.0f, 20.0f},
|
||||
{"shakePerShot", &Tuning::shakePerShot, 0.05f, 0.0f, 4.0f},
|
||||
// --- кровь, дымка, пепел ---
|
||||
{"bloodAmount", &Tuning::bloodAmount, 0.05f, 0.0f, 3.0f},
|
||||
{"bloodOpacity", &Tuning::bloodOpacity, 0.02f, 0.0f, 1.0f},
|
||||
{"bloodDrops", &Tuning::bloodDrops, 1.0f, 0.0f, 16.0f},
|
||||
{"hazeAmount", &Tuning::hazeAmount, 0.02f, 0.0f, 2.0f},
|
||||
{"hazeScale", &Tuning::hazeScale, 0.05f, 0.2f, 4.0f},
|
||||
{"hazeDrift", &Tuning::hazeDrift, 0.01f, 0.0f, 1.0f},
|
||||
{"hazeGate", &Tuning::hazeGate, 1.0f, 0.0f, 128.0f},
|
||||
{"ashDensity", &Tuning::ashDensity, 0.05f, 0.0f, 4.0f},
|
||||
{"ashSpeed", &Tuning::ashSpeed, 0.05f, 0.0f, 3.0f},
|
||||
{"ashBright", &Tuning::ashBrightness, 0.05f, 0.0f, 2.0f},
|
||||
{"ashLift", &Tuning::ashLiftPx, 1.0f, 0.0f, 40.0f},
|
||||
};
|
||||
|
||||
inline constexpr int TUNABLE_COUNT = int(sizeof(TUNABLES) / sizeof(TUNABLES[0]));
|
||||
205
src/world/tilemap.cpp
Normal file
205
src/world/tilemap.cpp
Normal file
@ -0,0 +1,205 @@
|
||||
#include "world/tilemap.h"
|
||||
|
||||
#include <limits>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr float INF_F = std::numeric_limits<float>::infinity();
|
||||
|
||||
} // namespace
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Уровень. Всё захардкожено, ни одного случайного числа — карта детерминирована.
|
||||
//
|
||||
// y 0..12 — северная комната (кучка мишеней)
|
||||
// y 13..15 — стена-разделитель с двумя проходами шириной 2 тайла
|
||||
// y 16..45 — центральная арена + западная и восточная комнаты за стенами
|
||||
// -----------------------------------------------------------------------------
|
||||
void Tilemap::Generate()
|
||||
{
|
||||
for (int i = 0; i < MAP_W * MAP_H; ++i) tiles[i] = Tile::EMPTY;
|
||||
|
||||
auto fill = [&](int x0, int y0, int x1, int y1, Tile t) {
|
||||
for (int y = y0; y <= y1; ++y)
|
||||
for (int x = x0; x <= x1; ++x)
|
||||
Set(x, y, t);
|
||||
};
|
||||
auto column = [&](int x, int y) { Set(x, y, Tile::WALL); };
|
||||
|
||||
// --- сплошная стена по периметру (2 тайла) ---
|
||||
fill(0, 0, MAP_W - 1, 1, Tile::WALL);
|
||||
fill(0, MAP_H - 2, MAP_W - 1, MAP_H - 1, Tile::WALL);
|
||||
fill(0, 0, 1, MAP_H - 1, Tile::WALL);
|
||||
fill(MAP_W - 2, 0, MAP_W - 1, MAP_H - 1, Tile::WALL);
|
||||
|
||||
// --- северная стена-разделитель, два прохода шириной 2 ---
|
||||
fill(2, 13, 45, 15, Tile::WALL);
|
||||
fill(10, 13, 11, 15, Tile::EMPTY);
|
||||
fill(30, 13, 31, 15, Tile::EMPTY);
|
||||
|
||||
// --- западная стена-разделитель, проход шириной 2 ---
|
||||
fill(12, 20, 14, 44, Tile::WALL);
|
||||
fill(12, 30, 14, 31, Tile::EMPTY);
|
||||
|
||||
// --- восточная стена-разделитель, проход шириной 2 ---
|
||||
fill(38, 18, 40, 40, Tile::WALL);
|
||||
fill(38, 27, 40, 28, Tile::EMPTY);
|
||||
|
||||
// --- колонны и огрызки стен на арене (без них не проверить LOS) ---
|
||||
const int arenaCols[][2] = {
|
||||
{18, 19}, {18, 27}, {18, 34}, {24, 19}, {24, 34},
|
||||
{30, 28}, {30, 34}, {26, 24}, {21, 23}, {35, 22}, {35, 36},
|
||||
};
|
||||
for (const auto& c : arenaCols) column(c[0], c[1]);
|
||||
|
||||
fill(20, 30, 22, 30, Tile::WALL); // огрызок-стенка поперёк
|
||||
fill(27, 38, 27, 40, Tile::WALL); // огрызок вдоль
|
||||
fill(33, 30, 34, 31, Tile::WALL); // блок 2x2
|
||||
|
||||
// --- северная комната ---
|
||||
const int northCols[][2] = {{8, 8}, {14, 5}, {30, 9}, {38, 6}, {42, 10}, {17, 10}, {35, 4}};
|
||||
for (const auto& c : northCols) column(c[0], c[1]);
|
||||
fill(21, 9, 23, 9, Tile::WALL);
|
||||
|
||||
// --- западная комната ---
|
||||
const int westCols[][2] = {{5, 25}, {8, 30}, {4, 35}, {10, 38}, {6, 21}};
|
||||
for (const auto& c : westCols) column(c[0], c[1]);
|
||||
fill(4, 28, 4, 30, Tile::WALL);
|
||||
|
||||
// --- восточная комната ---
|
||||
const int eastCols[][2] = {{43, 22}, {42, 30}, {44, 38}};
|
||||
for (const auto& c : eastCols) column(c[0], c[1]);
|
||||
|
||||
// --- южная полоса ---
|
||||
const int southCols[][2] = {{20, 43}, {28, 42}, {34, 44}, {16, 41}};
|
||||
for (const auto& c : southCols) column(c[0], c[1]);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// DDA Amanatides & Woo.
|
||||
// -----------------------------------------------------------------------------
|
||||
bool Tilemap::RaycastBlocked(Vec2 a, Vec2 b, float* tHit) const
|
||||
{
|
||||
int x = int(std::floor(a.x));
|
||||
int y = int(std::floor(a.y));
|
||||
if (IsWall(x, y)) { if (tHit) *tHit = 0.0f; return true; }
|
||||
|
||||
const Vec2 d = b - a;
|
||||
const float len = Length(d);
|
||||
if (len < 1e-6f) return false;
|
||||
|
||||
const Vec2 dir = d / len;
|
||||
const int stepX = (dir.x > 0.0f) ? 1 : ((dir.x < 0.0f) ? -1 : 0);
|
||||
const int stepY = (dir.y > 0.0f) ? 1 : ((dir.y < 0.0f) ? -1 : 0);
|
||||
|
||||
const float tDeltaX = (stepX != 0) ? std::fabs(1.0f / dir.x) : INF_F;
|
||||
const float tDeltaY = (stepY != 0) ? std::fabs(1.0f / dir.y) : INF_F;
|
||||
|
||||
float tMaxX = INF_F;
|
||||
float tMaxY = INF_F;
|
||||
if (stepX > 0) tMaxX = (float(x + 1) - a.x) / dir.x;
|
||||
else if (stepX < 0) tMaxX = (float(x) - a.x) / dir.x;
|
||||
if (stepY > 0) tMaxY = (float(y + 1) - a.y) / dir.y;
|
||||
else if (stepY < 0) tMaxY = (float(y) - a.y) / dir.y;
|
||||
|
||||
const int endX = int(std::floor(b.x));
|
||||
const int endY = int(std::floor(b.y));
|
||||
|
||||
for (;;)
|
||||
{
|
||||
if (x == endX && y == endY) return false;
|
||||
|
||||
float tEnter;
|
||||
if (tMaxX < tMaxY)
|
||||
{
|
||||
if (tMaxX > len) return false;
|
||||
tEnter = tMaxX;
|
||||
x += stepX;
|
||||
tMaxX += tDeltaX;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (tMaxY > len) return false;
|
||||
tEnter = tMaxY;
|
||||
y += stepY;
|
||||
tMaxY += tDeltaY;
|
||||
}
|
||||
|
||||
if (IsWall(x, y))
|
||||
{
|
||||
if (tHit) *tHit = Clampf(tEnter / len, 0.0f, 1.0f);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Circle vs AABB тайлов: выталкивание по кратчайшему направлению.
|
||||
// -----------------------------------------------------------------------------
|
||||
void Tilemap::ResolveCircle(Vec2& p, float r) const
|
||||
{
|
||||
for (int iter = 0; iter < 3; ++iter)
|
||||
{
|
||||
bool moved = false;
|
||||
const int x0 = int(std::floor(p.x - r));
|
||||
const int x1 = int(std::floor(p.x + r));
|
||||
const int y0 = int(std::floor(p.y - r));
|
||||
const int y1 = int(std::floor(p.y + r));
|
||||
|
||||
for (int ty = y0; ty <= y1; ++ty)
|
||||
{
|
||||
for (int tx = x0; tx <= x1; ++tx)
|
||||
{
|
||||
if (!IsWall(tx, ty)) continue;
|
||||
|
||||
const Vec2 closest{Clampf(p.x, float(tx), float(tx + 1)),
|
||||
Clampf(p.y, float(ty), float(ty + 1))};
|
||||
Vec2 delta = p - closest;
|
||||
const float dist = Length(delta);
|
||||
|
||||
if (dist > 1e-5f)
|
||||
{
|
||||
if (dist >= r) continue;
|
||||
p += (delta / dist) * (r - dist);
|
||||
moved = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// центр внутри тайла — выталкиваем по ближайшей грани
|
||||
const float dl = p.x - float(tx);
|
||||
const float dr = float(tx + 1) - p.x;
|
||||
const float dt = p.y - float(ty);
|
||||
const float db = float(ty + 1) - p.y;
|
||||
const float m = std::min(std::min(dl, dr), std::min(dt, db));
|
||||
if (m == dl) p.x = float(tx) - r;
|
||||
else if (m == dr) p.x = float(tx + 1) + r;
|
||||
else if (m == dt) p.y = float(ty) - r;
|
||||
else p.y = float(ty + 1) + r;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
}
|
||||
|
||||
bool Tilemap::CircleHitsWall(Vec2 p, float r) const
|
||||
{
|
||||
const int x0 = int(std::floor(p.x - r));
|
||||
const int x1 = int(std::floor(p.x + r));
|
||||
const int y0 = int(std::floor(p.y - r));
|
||||
const int y1 = int(std::floor(p.y + r));
|
||||
|
||||
for (int ty = y0; ty <= y1; ++ty)
|
||||
{
|
||||
for (int tx = x0; tx <= x1; ++tx)
|
||||
{
|
||||
if (!IsWall(tx, ty)) continue;
|
||||
const Vec2 closest{Clampf(p.x, float(tx), float(tx + 1)),
|
||||
Clampf(p.y, float(ty), float(ty + 1))};
|
||||
if (DistanceSq(p, closest) < r * r) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
42
src/world/tilemap.h
Normal file
42
src/world/tilemap.h
Normal file
@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Карта тайлов: EMPTY / WALL. Декартов мир, тайл = 1.0 юнита.
|
||||
// Здесь же DDA-рейкаст (Amanatides & Woo) и circle-vs-AABB разрешение коллизий.
|
||||
// Об изометрии этот модуль не знает.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/math.h"
|
||||
#include "tuning.h"
|
||||
|
||||
enum class Tile : uint8_t
|
||||
{
|
||||
EMPTY = 0,
|
||||
WALL = 1,
|
||||
};
|
||||
|
||||
struct Tilemap
|
||||
{
|
||||
Tile tiles[MAP_W * MAP_H] = {};
|
||||
|
||||
void Generate(); // единственный захардкоженный уровень
|
||||
|
||||
static bool InBounds(int x, int y) { return unsigned(x) < unsigned(MAP_W) && unsigned(y) < unsigned(MAP_H); }
|
||||
|
||||
Tile At(int x, int y) const { return InBounds(x, y) ? tiles[y * MAP_W + x] : Tile::WALL; }
|
||||
void Set(int x, int y, Tile t) { if (InBounds(x, y)) tiles[y * MAP_W + x] = t; }
|
||||
|
||||
// Вне карты считаем стеной — так рейкасты и коллизии не убегают наружу.
|
||||
bool IsWall(int x, int y) const { return At(x, y) == Tile::WALL; }
|
||||
bool IsWallAt(Vec2 p) const { return IsWall(int(std::floor(p.x)), int(std::floor(p.y))); }
|
||||
|
||||
// Amanatides & Woo. true, если отрезок a->b пересекает стену. Без аллокаций.
|
||||
// tHit (если нужен) — параметр входа в стену вдоль отрезка, [0..1].
|
||||
bool RaycastBlocked(Vec2 a, Vec2 b, float* tHit = nullptr) const;
|
||||
|
||||
// Выталкивание круга из стен (несколько итераций — из-за углов).
|
||||
void ResolveCircle(Vec2& p, float r) const;
|
||||
|
||||
// Есть ли стена в круге радиуса r вокруг p (для отбраковки кандидатов).
|
||||
bool CircleHitsWall(Vec2 p, float r) const;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user