From 9bb979727158368be16facd51d7ed2ebf1fada1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl?= Date: Thu, 22 Oct 2026 14:18:49 +0200 Subject: [PATCH] Adding patch notes from git, UI, and loading files from the main install path properly. Also fixed: * flying wands not pathing properly. * Lantern particles offset * Wand fire offset * Water spray from flask --- CMakeLists.txt | 8 + shaders/entities.comp | 13 +- shaders/fx_hand.comp | 1 + shaders/fx_particles.comp | 21 +- shaders/particle_req.comp | 7 + shaders/render.comp | 10 +- shaders/sprites.comp | 2 +- src/ai/pathfind.cpp | 4 +- src/ai/pathfind.h | 1 + src/app/hud.h | 24 +- src/app/main.cpp | 780 ++++++++++++++++++++++++++++++++------ src/app/menu.h | 765 +++++++++++++++++++++++++++++++++++++ src/audio/audio_ipc.h | 2 +- src/audio/fmod_audio.cpp | 5 +- src/audio/fmod_audio.h | 3 + src/entity/potion_spray.h | 42 ++ src/lua/host_natives.cpp | 5 +- src/lua/lua_host.cpp | 4 +- src/lua/lua_host.h | 6 +- 19 files changed, 1569 insertions(+), 134 deletions(-) create mode 100644 src/app/menu.h create mode 100644 src/entity/potion_spray.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9db43c4..114c5e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -67,12 +67,20 @@ target_include_directories(ngblosc INTERFACE ${CMAKE_SOURCE_DIR}/third_party/c-b add_executable(noita_gpu src/app/main.cpp src/audio/fmod_audio.cpp) target_link_libraries(noita_gpu PRIVATE ngspawn ngblosc nggpulua Vulkan::Vulkan) + +#for changelog # the short git hash of the sources at configure time (the GIF metadata's "build", docs/GIF_METADATA.md) execute_process(COMMAND git rev-parse --short HEAD WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} OUTPUT_VARIABLE NG_GIT_HASH OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) if(NOT NG_GIT_HASH) set(NG_GIT_HASH "dev") endif() target_compile_definitions(noita_gpu PRIVATE NG_BUILD="${NG_GIT_HASH}") +# the release notes of the menu (src/app/menu.h): the git log as "hash|date|subject" lines, beside the executable and in the demo folder +execute_process(COMMAND git log -n 400 --date=short "--pretty=format:%h|%ad|%s" WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} OUTPUT_VARIABLE NG_GIT_LOG ERROR_QUIET) +file(WRITE ${CMAKE_BINARY_DIR}/release_notes.txt "${NG_GIT_LOG}\n") +add_custom_command(TARGET noita_gpu POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_BINARY_DIR}/release_notes.txt $/release_notes.txt + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_BINARY_DIR}/release_notes.txt ${CMAKE_SOURCE_DIR}/demo/release_notes.txt) if(MSVC) target_compile_options(noita_gpu PRIVATE /W4 /permissive- /bigobj) endif() diff --git a/shaders/entities.comp b/shaders/entities.comp index 4b2e507..a884faa 100644 --- a/shaders/entities.comp +++ b/shaders/entities.comp @@ -45,6 +45,8 @@ layout(std430, set = 0, binding = 23) buffer Ctrl { ivec4 hand1; // host: held wand: texel offset, w, h vec4 hand2; // host: held wand: pivot ivec4 burnN; // rigid_burn.comp: x the burnt pixels; y: the player's respawns, z: saved by SAVING_GRACE, w: an extra life used (the host reads and clears them) + ivec4 burnEv[256]; ivec4 reactN; ivec4 reactEv[64]; + ivec4 extra; // host: x = 1: a dead player stays dead where it fell (the game over of the menus) instead of respawning } ctrl; #include "hit_liquid.glsl" @@ -303,9 +305,14 @@ void main() { if (hp <= 0.0 && (e.ex8.x & 16) != 0 && hpBeforeHit > 0.04) { hp = 0.04; atomicAdd(ctrl.burnN.z, 1); } else if (hp <= 0.0 && (ents[id].ex8.x & 131072) != 0) { hp = min(4.0, e.aux.x > 0 ? float(e.aux.x) * 0.001 : 4.0); atomicAnd(ents[id].ex8.x, ~131072); atomicAdd(ctrl.burnN.w, 1); } if (hp <= 0.0 && (e.ex8.x & 32768) == 0) { // respawn at the spawn point (a polymorphed body is not: the host ends the polymorph, 32768) - pos = vec2(float(e.x.z), float(e.x.w)); vel = vec2(0.0); ip = ivec2(floor(pos)); - hp = e.aux.x > 0 ? float(e.aux.x) * 0.001 : 4.0; - atomicAdd(ctrl.burnN.y, 1); + if (ctrl.extra.x != 0) { // the game over (menu.h): the body stays where it fell, dead - no controls, counted once for the host + if ((flags & ST_DEAD) == 0) atomicAdd(ctrl.burnN.y, 1); + flags |= ST_DEAD; hp = 0.0; target = 0.0; want_jump = false; thrust = false; + } else { + pos = vec2(float(e.x.z), float(e.x.w)); vel = vec2(0.0); ip = ivec2(floor(pos)); + hp = e.aux.x > 0 ? float(e.aux.x) * 0.001 : 4.0; + atomicAdd(ctrl.burnN.y, 1); + } } } else if (kind == 1 || kind == 2) { // ---- creatures ---- // the target of the creature: the player; a charmed creature (game effect CHARM) fights the nearest creature of another herd instead diff --git a/shaders/fx_hand.comp b/shaders/fx_hand.comp index d6bd6bd..4a77cd8 100644 --- a/shaders/fx_hand.comp +++ b/shaders/fx_hand.comp @@ -91,6 +91,7 @@ void main() { if (mirror) hl.y = -hl.y; hand = root + vec2(c * hl.x - s * hl.y, s * hl.x + c * hl.y); } + if (ctrl.hand1.w == 1) hand += vec2(mirror ? -0.5 : 0.5, 0.5); // flask entity transform, matching the host spray origin { // the item's recoil (00b51670): hand2.z px back along the aim, turned muzzle-up by hand0.w radians (the arm keeps the aim) const float ia = ang + (mirror ? ctrl.hand0.w : -ctrl.hand0.w); const float ic = cos(ia), is = sin(ia); diff --git a/shaders/fx_particles.comp b/shaders/fx_particles.comp index 17c7d79..6fca5fc 100644 --- a/shaders/fx_particles.comp +++ b/shaders/fx_particles.comp @@ -62,7 +62,8 @@ void displaced_particle(uint id, FPart p) { fparts[id].c.w = p.c.w + 1u; return; } - const float g = 0.1667; + const bool spray = (p.c.w & 0x200000u) != 0u; + const float g = spray ? PART_GRAV : 0.1667; vec2 target = pos + vel; vel.y += g * (kind == 1u ? 0.25 : 1.0); if (kind == 1u && vel.y > 0.0) kind = 2u; @@ -81,14 +82,14 @@ void displaced_particle(uint id, FPart p) { uint m = cell_mat(cells[q.y * pc.gw + q.x]); if (m != 0u) { uint f = mflags(m); - if ((f & F_STATIC) != 0u) { dead = true; break; } - if ((f & F_LIQUID) != 0u) { + if ((f & F_STATIC) != 0u && !(spray && kind == 1u)) { dead = true; break; } + if ((f & F_LIQUID) != 0u || (spray && (f & F_STATIC) != 0u)) { if (kind == 2u) { dead = true; break; } // mode 0 stops at liquid (and powder) float eff = (vel.y > -0.83 || (p.c.w & 0x100000u) != 0u) ? 0.0 : dens; // mode 1: effective density 0 (an upward speed below 50 px/s, or a draw at creation) unless moving up fast; it must be lighter than the target if (!(eff < mdensity(m)) || budget == 0u) { dead = true; break; } budget -= 1u; - } - } + } else if (spray) kind = 2u; + } else if (spray) kind = 2u; // 00712390: leaving material ends the mode-1 penetration. cur = nx; } if (!dead && int(mlen) <= 60) { // the target cell itself @@ -98,15 +99,15 @@ void displaced_particle(uint id, FPart p) { uint m = cell_mat(cells[q.y * pc.gw + q.x]); if (m != 0u) { uint f = mflags(m); - if ((f & F_STATIC) != 0u) dead = true; - else if ((f & F_LIQUID) != 0u) { + if ((f & F_STATIC) != 0u && !(spray && kind == 1u)) dead = true; + else if ((f & F_LIQUID) != 0u || (spray && (f & F_STATIC) != 0u)) { if (kind == 2u) dead = true; else { float eff = (vel.y > -0.83 || (p.c.w & 0x100000u) != 0u) ? 0.0 : dens; if (!(eff < mdensity(m)) || budget == 0u) dead = true; else budget -= 1u; } - } - } + } else if (spray) kind = 2u; + } else if (spray) kind = 2u; if (!dead) cur = target; } } else if (!dead) cur = target; // beyond the 60 probes the end point is taken as it is @@ -116,7 +117,7 @@ void displaced_particle(uint id, FPart p) { kind = 3u; // no place yet: it keeps trying } fparts[id].a = vec4(pos, vel); - fparts[id].c = uvec4(cw, kind, budget, (p.c.w & 0x100000u) | ((p.c.w & 0xFFFFFu) + 1u)); + fparts[id].c = uvec4(cw, kind, budget, (p.c.w & 0x300000u) | ((p.c.w & 0xFFFFFu) + 1u)); ivec2 q = ivec2(floor(pos)) - ivec2(pc.view_x, pc.view_y); if (q.x >= 0 && q.y >= 0 && q.x < pc.gw && q.y < pc.gh) { vec3 col = cell_colour(cw); diff --git a/shaders/particle_req.comp b/shaders/particle_req.comp index 005c476..348f5e4 100644 --- a/shaders/particle_req.comp +++ b/shaders/particle_req.comp @@ -33,6 +33,13 @@ void main() { if (old == 0u) { ext[ci] = vec2(0.0); birth[ci] = birth_frame(); } else if ((r.c.w & 2u) == 0u && (mflags(cell_mat(old)) & (F_GAS | F_LIQUID)) != 0u && (mflags(cell_mat(old)) & (F_STATIC | F_SAND)) == 0u && atomicCompSwap(cells[ci], old, nc) == old) { ext[ci] = vec2(0.0); birth[ci] = birth_frame(); } } + else if (r.c.y == 7u) { // PotionSystem 00bfdd10: mode 1, density zero, 20-cell penetration budget. + const int ps = alloc_part(); + if (ps < 0) return; + fparts[ps].a = r.a; + fparts[ps].b = vec4(r.b.x, 1.0, uintBitsToFloat(birth_frame()), 0.0); + fparts[ps].c = uvec4(r.c.z | (((hash3(i, uint(pc.frame), 64u) >> 16) & 0xFFu) << 16), 1u, 20u, 0x300000u); + } else if (r.c.y == 6u) emit_displaced(r.a.xy, r.a.zw, r.c.z | (((hash3(i, uint(pc.frame), 64u) >> 16) & 0xFFu) << 16), 2u, birth_frame()); // GameCreateParticle (007b3120 -> 00716af0): a cell of the material handed to the particle system like a displaced one (AddAsParticle 007168c0): it falls and goes back into the grid, waiting for a free place else emit_material_particle(r.a.xy, r.a.zw, r.b.x, r.b.y, r.c.x, int(r.c.z)); } diff --git a/shaders/render.comp b/shaders/render.comp index c6fb974..caf2c40 100644 --- a/shaders/render.comp +++ b/shaders/render.comp @@ -27,7 +27,7 @@ layout(std430, set = 0, binding = 39) readonly buffer EntPrev { vec4 prevpos[]; layout(std430, set = 0, binding = 26) readonly buffer Overlay { uint overlay[]; }; // CPU-drawn HUD (ARGB, output pixels) struct FLight { vec4 a; vec4 b; }; // x, y, radius (world cells), strength / rgb -layout(std430, set = 0, binding = 23) readonly buffer Ctrl { ivec4 inp; ivec4 inp2; ivec4 inp3; vec4 outp; vec4 outp2; vec4 view; vec4 hand0; ivec4 hand1; vec4 hand2; } ctrl; +layout(std430, set = 0, binding = 23) readonly buffer Ctrl { ivec4 inp; ivec4 inp2; ivec4 inp3; vec4 outp; vec4 outp2; vec4 view; vec4 hand0; ivec4 hand1; vec4 hand2; ivec4 burnN; ivec4 burnEv[256]; ivec4 reactN; ivec4 reactEv[64]; ivec4 extra; } ctrl; // extra.yzw: the Options' brightness, contrast, gamma x 10000 (main.cpp) layout(std430, set = 0, binding = 38) readonly buffer FxLights { uint lcount; uint lpad[3]; FLight lights[512]; }; // lights of projectiles and explosions this frame layout(std430, set = 0, binding = 44) readonly buffer Sky { uint sky[]; }; // src/world/sky.h @@ -423,7 +423,7 @@ void main() { if (p.x >= pc.ow || p.y >= pc.oh) return; ivec2 cp = clamp(ivec2(floor(vec2(p + so + vis) / scl)), ivec2(0), ivec2(pc.gw - 1, pc.gh - 1)); vec3 rgb = unpackUnorm4x8(wcell[cp.y * pc.gw + cp.x].x).rgb; // (only the colour: the light is read for the player's pixels) - if (ctrl.inp2.y != 0 && (ents[0].st.w & 8) == 0) { + if (ctrl.inp2.y != 0 && (ents[0].st.w & 8) == 0) { // (dead: the ragdoll of 00abb7d0 is the body, main.cpp onCreatureDeath(0, true)) vec2 P = ents[0].a.xy; const vec2 pv = prevpos[0].xy; if (distance(pv, P) < 10.0) P = mix(pv, P, ctrl.view.z); @@ -445,6 +445,12 @@ void main() { rgb += vec3(0.7, 0.1, 0.0) * a * lh; } } + if (ctrl.extra.y != 0 || ctrl.extra.z != 0 || ctrl.extra.w != 0) { // the Options' sliders (post_final.frag: + brightness, (c - 0.5) x contrast + 0.5, pow(c, gamma), clamped; 1 + the slider for the last two) + rgb += float(ctrl.extra.y) * 1e-4; + rgb = (rgb - 0.5) * (1.0 + float(ctrl.extra.z) * 1e-4) + 0.5; + rgb = pow(max(rgb, vec3(0.0)), vec3(1.0 + float(ctrl.extra.w) * 1e-4)); + rgb = clamp(rgb, 0.0, 1.0); + } { // the picture without the HUD for the replay recorder (the game's GIFs have no UI): clip_half.comp / main.cpp copy it const uvec3 q0 = uvec3(clamp(rgb, 0.0, 1.0) * 255.0 + 0.5); clipraw[p.y * pc.ow + p.x] = 0xFF000000u | (q0.r << 16) | (q0.g << 8) | q0.b; diff --git a/shaders/sprites.comp b/shaders/sprites.comp index cf18d92..4c55f8d 100644 --- a/shaders/sprites.comp +++ b/shaders/sprites.comp @@ -62,7 +62,7 @@ void main() { ivec4 h0 = entinst[4u * id], hi = entinst[4u * id + 1u], hw = entinst[4u * id + 2u]; Ent e = ents[h0.x]; gKey = (uint(clamp(h0.w + 32768, 0, 65535)) << 16) | (65535u - (id & 65535u)); - if ((e.st.w & (8 | 4096)) != 0) return; // dead, or its sprites switched off (4096: a creature, main.cpp on_components_enabled) + if ((e.st.w & (8 | 4096)) != 0) return; // dead (the host draws the ragdoll), or its sprites switched off (4096: a creature, main.cpp on_components_enabled) if (e.st.x != 4 && (e.ex8.x & 8192) != 0) return; { // the hit flash: creatures, the player and physics items (a worm segment, kind 0, keeps its root slot in ex19.w) const int fc = int(e.ex19.w + 0.5); diff --git a/src/ai/pathfind.cpp b/src/ai/pathfind.cpp index 1e49829..f2bc049 100644 --- a/src/ai/pathfind.cpp +++ b/src/ai/pathfind.cpp @@ -142,7 +142,7 @@ std::vector search_pass(const PathGrid& g, float sx, float sy, const S else if (g.valid(snx, sny - d) && !g.solid(snx, sny - d)) { sny -= d; moved = true; } } if (!moved) return out; - } else if (!p.can_fly && !g.solid(snx, sny + 1) && !(g.at(snx, sny + 1) & kNodeLiquid) && !(g.at(snx, sny) & kNodeLiquid)) { + } else if (!p.can_fly && !p.flyer && !g.solid(snx, sny + 1) && !(g.at(snx, sny + 1) & kNodeLiquid) && !(g.at(snx, sny) & kNodeLiquid)) { // (a flyer starts where it is, in the air: 00861e00 +0x24 at the start) for (int d = 1; d <= 3; ++d) { if (!g.valid(snx, sny + d) || g.solid(snx, sny + d) || (g.at(snx, sny + d) & kNodeLiquid)) break; sny += 1; @@ -339,7 +339,7 @@ std::vector search(const PathGrid& g, float sx, float sy, const Search bool budget = false; std::vector partial; if (p.can_walk || p.can_dive || p.can_swim_on_surface) { - SearchParams q = p; q.can_fly = false; q.can_jump = false; + SearchParams q = p; q.can_fly = false; q.can_jump = false; q.flyer = p.can_fly; std::vector r = search_pass(g, sx, sy, q, budget); if (!r.empty() && !budget) return r; if (budget) partial = std::move(r); diff --git a/src/ai/pathfind.h b/src/ai/pathfind.h index 19635a5..4ffe8a0 100644 --- a/src/ai/pathfind.h +++ b/src/ai/pathfind.h @@ -54,6 +54,7 @@ enum class Goal { None, Hunt, RandomMove, Goto, MoveNear, Burning, Away, Escape, struct SearchParams { bool can_fly = false, can_walk = true, can_jump = false, can_dive = false, can_swim_on_surface = false; + bool flyer = false; // (set by search(): the creature can fly - 00861e00 decides the start projection on the original can_fly, before the passes turn it off) int clearance_required = 0; // space_required / 4 (00bca030: input +0xcc) float cost_of_flying = 20.0f; int depth_max = 50; // nodes along the path (AnimalAI pathfinding_max_depth_has_target / _no_target) diff --git a/src/app/hud.h b/src/app/hud.h index c5003ea..88e6227 100644 --- a/src/app/hud.h +++ b/src/app/hud.h @@ -49,6 +49,23 @@ struct Icon { bool ok() const { return w > 0; } }; +// data/shaders/imgui_potion_icon.frag: the upper part retains the glass colour. +inline int potion_fill_row(int height, float fraction) { + return std::clamp((int)std::ceil(height * (0.85f - 0.67f * std::max(0.1f, fraction)) - 0.5f), 0, height); +} +inline Icon potion_icon(const Icon& base, uint32_t material, int fill_row) { + Icon out = base; + for (int y = 0; y < out.h; ++y) for (int x = 0; x < out.w; ++x) { + uint32_t& p = out.px[(size_t)y * out.w + x]; + auto channel = [&](int shift) { + const float tint = y >= fill_row ? ((material >> shift) & 255) / 255.0f : 1.0f; + return (uint32_t)(((p >> shift) & 255) * tint * 0.85f); + }; + p = (p & 0xFF000000u) | channel(16) << 16 | channel(8) << 8 | channel(0); + } + return out; +} + inline Icon load_icon(const ng::Wak& wak, const std::string& path) { Icon ic; std::vector raw; @@ -285,7 +302,7 @@ struct State { struct WandInfo { const Icon* icon = nullptr; std::string name; int cards = 0, capacity = 0, uses = -1; }; // uses: the uses left of its first limited spell (-1 none) std::vector wands; // inventory slots int active_wand = -1; - struct ItemInfo { const Icon* icon = nullptr; float frac = 1.0f; std::string name; bool empty = false; }; // an item of the quick inventory (flask): icon, how full it is + struct ItemInfo { const Icon* icon = nullptr; std::string name; bool empty = false; }; // flask icon includes its liquid level std::vector items; // slots 5-8 int active_slot = -1; // highlighted slot (0-7) when it is an item std::vector spell_icons; // spells of the active wand, in deck order @@ -322,6 +339,7 @@ struct State { bool drag_on = false; const Icon* drag_icon = nullptr; int drag_bg = -1; int drag_x = 0, drag_y = 0; // the item on the cursor std::vector tip; // hover information int tip_x = 0, tip_y = 0; + std::string cursor_material; // ---- the text sprites of the world (shop prices: SpriteComponent is_text_sprite, font_pixel_white), top-left corner in output pixels, scale = output pixels per world pixel ---- std::vector world_texts; // ---- overlays drawn over everything (the information box of a wand in the world, the new-wand choice): style 2 the dark information box, 3 the orange banner ---- @@ -434,8 +452,6 @@ inline void draw(Canvas& cv, const Assets& as, const State& s) { if (i >= 4 && i - 4 < (int)s.items.size() && !s.items[i - 4].empty) { const State::ItemInfo& it = s.items[i - 4]; if (it.icon && it.icon->ok()) cv.blit_centered(it.icon->px.data(), it.icon->w, it.icon->h, x + slot / 2, y0 + slot / 2, std::max(1, std::min(ui, (slot * 3 / 4) / std::max(it.icon->w, 1)))); - cv.rect(x + 2 * ui, y0 + slot - 3 * ui, slot - 4 * ui, 2 * ui, rgba(0, 0, 0, 170)); // how full the flask is - cv.rect(x + 2 * ui, y0 + slot - 3 * ui, (int)((slot - 4 * ui) * std::clamp(it.frac, 0.0f, 1.0f)), 2 * ui, rgba(110, 170, 230)); } if (i < 4 && i < (int)s.wands.size() && s.wands[i].icon && s.wands[i].icon->ok()) { const Icon& ic = *s.wands[i].icon; @@ -443,6 +459,8 @@ inline void draw(Canvas& cv, const Assets& as, const State& s) { if (s.wands[i].uses >= 0) cv.text(x + 2 * ui, y0 + 2 * ui, std::to_string(s.wands[i].uses), rgba(255, 255, 255), std::max(1, ts)); // the first limited spell's uses left } } + if (!s.cursor_material.empty() && !s.inv_open && !s.paused) + cv.text(x0 + 8 * pitch + 5 * ui, y0 + (slot - 7 * ts) / 2, s.cursor_material, white, ts); // spells of the active wand, under the quick inventory const int sy = y0 + slot + 3 * ui; for (size_t i = 0; i < s.spell_icons.size() && !s.inv_open; ++i) { diff --git a/src/app/main.cpp b/src/app/main.cpp index 5bd42a0..d453c87 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -26,6 +26,7 @@ #include #include "entity/material_mix.h" #include "entity/hit_liquid.h" +#include "entity/potion_spray.h" #include #include #include @@ -38,6 +39,7 @@ #include "app/perf.h" #include "app/gif_editor.h" #include "app/hud.h" +#include "app/menu.h" #include "app/rigid_debug.h" #include "entity/runtime.h" #include "lua/gpu_lua.h" @@ -262,9 +264,12 @@ struct Input { bool collider_toggle = false, rigid_dump = false; bool mouse_in = false; int cw = 0, ch = 0; // client size of the window (WM_SIZE) + int last_key = 0; // the virtual key that went down this frame (the Options' key rebinding) int move_x = 0, move_y = 0; // view movement requested by the arrow keys / WASD (world mode) } g_in; bool g_modal = false; // a modal window is open: Esc closes it +bool g_menus = false; // the menus are on: Esc opens the pause menu (never quits) +int g_keys[9] = {'A', 'D', 'W', 'S', 'F', 'E', VK_TAB, 'Q', 'P'}; // left, right, up, down, kick, interact, inventory, drop, pause (menu::Settings::keys, the Options' Input tab) bool g_gifOpen = false; // the replay clip editor is open: the keys and the mouse buttons go to it bool g_textCapture = false; // a text field has the keyboard: letters, digits and Backspace go to it, not to the game keys @@ -303,24 +308,25 @@ LRESULT CALLBACK wnd_proc(HWND h, UINT m, WPARAM w, LPARAM l) { } if (w == VK_F11) { g_in.gif_toggle = true; return 0; } if (g_gifOpen) { if (w == VK_F9) g_in.clip_dump = true; else g_in.gif_keys.push_back((int)w); return 0; } - if (w == VK_ESCAPE) { if (g_modal) g_in.escape = true; else g_in.quit = true; } + if (w == VK_ESCAPE) { if (g_modal || g_menus) g_in.escape = true; else g_in.quit = true; } if (g_textCapture && ((w >= 'A' && w <= 'Z') || (w >= '0' && w <= '9') || w == VK_SPACE || w == VK_BACK || w == VK_OEM_MINUS || w == VK_OEM_2)) { if (w == VK_BACK) ++g_in.backspace; return 0; } // (WM_CHAR brings the text) if (w == 'C') g_in.cheat_panel = true; if (w == 'K') g_in.cheat_wand = true; if (w == 'M') g_in.wand_maker = true; if (w >= '1' && w <= '8') { g_in.select = (int)(w - '1'); g_in.select_new = true; } - if (w == 'F') g_in.attack = true; - if (w == 'P') g_in.pause_toggle = true; - if (w == 'E') g_in.interact = true; + g_in.last_key = (int)w; + if (w == g_keys[4]) g_in.attack = true; + if (w == g_keys[8]) g_in.pause_toggle = true; + if (w == g_keys[5]) g_in.interact = true; if (w == VK_ADD || w == VK_OEM_PLUS) g_in.speed_step = 1; if (w == VK_SUBTRACT || w == VK_OEM_MINUS) g_in.speed_step = -1; if (w == VK_F3 && !(l & (1LL << 30))) g_in.debug_toggle = true; if (w == VK_F1) g_in.help_toggle = true; if (w == 'B') g_in.brush_toggle = true; - if (w == VK_TAB || w == 'I') g_in.inv_toggle = true; + if (w == g_keys[6] || w == 'I') g_in.inv_toggle = true; if (w == VK_F2) g_in.toggle_cam = true; if (w == VK_F8) g_in.debug_mode = true; - if (w == 'Q') g_in.drop_item = true; + if (w == g_keys[7]) g_in.drop_item = true; if (w == VK_F9) g_in.clip_dump = true; if (w == VK_F4) g_in.light_toggle = true; if (w == VK_F5) g_in.sky_debug = true; @@ -330,10 +336,10 @@ LRESULT CALLBACK wnd_proc(HWND h, UINT m, WPARAM w, LPARAM l) { if (w == 'G') g_in.gold = true; if (w == VK_OEM_4) g_in.scroll_step = -1; if (w == VK_OEM_6) g_in.scroll_step = 1; - if (w == VK_LEFT || w == 'A') g_in.move_x = -1; - if (w == VK_RIGHT || w == 'D') g_in.move_x = 1; - if (w == VK_UP || w == 'W') g_in.move_y = -1; - if (w == VK_DOWN || w == 'S') g_in.move_y = 1; + if (w == VK_LEFT || w == g_keys[0]) g_in.move_x = -1; + if (w == VK_RIGHT || w == g_keys[1]) g_in.move_x = 1; + if (w == VK_UP || w == g_keys[2]) g_in.move_y = -1; + if (w == VK_DOWN || w == g_keys[3]) g_in.move_y = 1; return 0; case WM_CHAR: if (g_textCapture && w >= 32 && w < 127) g_in.typed += (char)w; return 0; case WM_CLOSE: case WM_DESTROY: g_in.quit = true; return 0; @@ -351,9 +357,11 @@ HWND make_window(const char* title) { RegisterClassA(&wc); DWORD style = WS_OVERLAPPEDWINDOW; RECT r{0, 0, kStartW, kStartH}; + int wx = CW_USEDEFAULT, wy = CW_USEDEFAULT; + if (const char* wp = std::getenv("NG_WINPOS")) { int x = 0, y = 0; if (std::sscanf(wp, "%d,%d", &x, &y) == 2) { wx = x; wy = y; } } // the window's place (a relaunch from the menus keeps it) if (const char* ws = std::getenv("NG_WIN")) { int w = 0, hh = 0; if (std::sscanf(ws, "%dx%d", &w, &hh) == 2 && w >= 320 && hh >= 200) { r.right = w; r.bottom = hh; } } // test: the client size at the start AdjustWindowRect(&r, style, FALSE); - HWND h = CreateWindowExA(g_scriptedRun ? WS_EX_NOACTIVATE : 0, wc.lpszClassName, title, style | (g_scriptedRun ? 0 : WS_VISIBLE), CW_USEDEFAULT, CW_USEDEFAULT, + HWND h = CreateWindowExA(g_scriptedRun ? WS_EX_NOACTIVATE : 0, wc.lpszClassName, title, style | (g_scriptedRun ? 0 : WS_VISIBLE), wx, wy, r.right - r.left, r.bottom - r.top, nullptr, nullptr, wc.hInstance, nullptr); if (h && g_scriptedRun) ShowWindow(h, SW_SHOWNOACTIVATE); // (shown without taking the focus) if (!h) throw std::runtime_error("CreateWindow failed"); @@ -535,6 +543,7 @@ VkPipeline make_pipeline(Gpu& g, const std::vector& code) { VkPipeline make_pipeline(Gpu& g, const std::string& path) { return make_pipeline(g, read_spv(path)); } // (Re)creates the swapchain for the current client size of the window (BGRA8, FIFO, copy destination). +bool g_vsync = true; // (menu::Settings::vsync) void create_swapchain(Gpu& g) { VkSurfaceCapabilitiesKHR caps; VK(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(g.phys, g.surf, &caps)); @@ -554,6 +563,12 @@ void create_swapchain(Gpu& g) { sc.preTransform = caps.currentTransform; sc.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; sc.presentMode = VK_PRESENT_MODE_FIFO_KHR; sc.clipped = VK_TRUE; + if (!g_vsync) { // the Options' Vsync off: immediate (or mailbox) presentation when the surface has it + uint32_t n = 0; vkGetPhysicalDeviceSurfacePresentModesKHR(g.phys, g.surf, &n, nullptr); + std::vector pm(n); if (n) vkGetPhysicalDeviceSurfacePresentModesKHR(g.phys, g.surf, &n, pm.data()); + for (VkPresentModeKHR m : pm) if (m == VK_PRESENT_MODE_IMMEDIATE_KHR) sc.presentMode = m; + if (sc.presentMode == VK_PRESENT_MODE_FIFO_KHR) for (VkPresentModeKHR m : pm) if (m == VK_PRESENT_MODE_MAILBOX_KHR) sc.presentMode = m; + } sc.oldSwapchain = g.swap; VkSwapchainKHR fresh{}; VK(vkCreateSwapchainKHR(g.dev, &sc, nullptr, &fresh)); @@ -871,6 +886,42 @@ static LONG WINAPI crash_trace(EXCEPTION_POINTERS* ep) { return EXCEPTION_EXECUTE_HANDLER; } +// The game's data.wak when none is given: beside the executable or above it, then the Steam install (the registry's Steam folder and every library of its +// libraryfolders.vdf: steamapps/common/Noita/data/data.wak). "" when nowhere. +static std::string find_game_wak() { + std::string wakPath; + char exe[MAX_PATH]; GetModuleFileNameA(nullptr, exe, MAX_PATH); + const std::filesystem::path d = std::filesystem::path(exe).parent_path(); + const std::filesystem::path cands[] = {d / "data.wak", d / "data" / "data.wak", d.parent_path() / "data" / "data.wak", d.parent_path().parent_path() / "data" / "data.wak", + d.parent_path() / "data.wak", d.parent_path().parent_path() / "data.wak", // (the game keeps it in Noita/data/; a data.wak beside may be an old copy) + "C:/Program Files (x86)/Steam/steamapps/common/Noita/data/data.wak"}; + std::error_code ec; + for (const auto& c : cands) if (std::filesystem::is_regular_file(c, ec)) { wakPath = c.string(); break; } + if (wakPath.empty()) { // the Steam libraries: Steam's folder (registry SteamPath / InstallPath), then every "path" of its steamapps/libraryfolders.vdf + std::vector steams; + char buf[MAX_PATH]; DWORD n = sizeof buf; + if (RegGetValueA(HKEY_CURRENT_USER, "Software\\Valve\\Steam", "SteamPath", RRF_RT_REG_SZ, nullptr, buf, &n) == ERROR_SUCCESS) steams.emplace_back(buf); + n = sizeof buf; + if (RegGetValueA(HKEY_LOCAL_MACHINE, "SOFTWARE\\WOW6432Node\\Valve\\Steam", "InstallPath", RRF_RT_REG_SZ, nullptr, buf, &n) == ERROR_SUCCESS) steams.emplace_back(buf); + std::vector libs = steams; + for (const auto& s : steams) { + std::ifstream vdf(s / "steamapps" / "libraryfolders.vdf"); + for (std::string line; std::getline(vdf, line);) { + const size_t k = line.find("\"path\""); + if (k == std::string::npos) continue; + const size_t q0 = line.find('"', k + 6), q1 = q0 == std::string::npos ? q0 : line.find('"', q0 + 1); + if (q1 == std::string::npos) continue; + std::string v = line.substr(q0 + 1, q1 - q0 - 1), u; + for (size_t i = 0; i < v.size(); ++i) { if (v[i] == '\\' && i + 1 < v.size() && v[i + 1] == '\\') ++i; u += v[i]; } // (the file escapes its backslashes) + libs.emplace_back(u); + } + } + for (const auto& l : libs) { const auto c = l / "steamapps" / "common" / "Noita" / "data" / "data.wak"; if (std::filesystem::is_regular_file(c, ec)) { wakPath = c.string(); break; } } + } + + return wakPath; +} + int main(int argc, char** argv) { SetUnhandledExceptionFilter(crash_trace); if (std::getenv("NG_UNBUF")) std::setvbuf(stdout, nullptr, _IONBF, 0); @@ -884,36 +935,7 @@ int main(int argc, char** argv) { std::string wakPath; const std::string a1 = argc >= 2 ? argv[1] : ""; if (a1.size() > 4 && a1.substr(a1.size() - 4) == ".wak") wakPath = a1; - if (wakPath.empty()) { - char exe[MAX_PATH]; GetModuleFileNameA(nullptr, exe, MAX_PATH); - const std::filesystem::path d = std::filesystem::path(exe).parent_path(); - const std::filesystem::path cands[] = {d / "data.wak", d / "data" / "data.wak", d.parent_path() / "data" / "data.wak", d.parent_path().parent_path() / "data" / "data.wak", - d.parent_path() / "data.wak", d.parent_path().parent_path() / "data.wak", // (the game keeps it in Noita/data/; a data.wak beside may be an old copy) - "C:/Program Files (x86)/Steam/steamapps/common/Noita/data/data.wak"}; - std::error_code ec; - for (const auto& c : cands) if (std::filesystem::is_regular_file(c, ec)) { wakPath = c.string(); break; } - if (wakPath.empty()) { // the Steam libraries: Steam's folder (registry SteamPath / InstallPath), then every "path" of its steamapps/libraryfolders.vdf - std::vector steams; - char buf[MAX_PATH]; DWORD n = sizeof buf; - if (RegGetValueA(HKEY_CURRENT_USER, "Software\\Valve\\Steam", "SteamPath", RRF_RT_REG_SZ, nullptr, buf, &n) == ERROR_SUCCESS) steams.emplace_back(buf); - n = sizeof buf; - if (RegGetValueA(HKEY_LOCAL_MACHINE, "SOFTWARE\\WOW6432Node\\Valve\\Steam", "InstallPath", RRF_RT_REG_SZ, nullptr, buf, &n) == ERROR_SUCCESS) steams.emplace_back(buf); - std::vector libs = steams; - for (const auto& s : steams) { - std::ifstream vdf(s / "steamapps" / "libraryfolders.vdf"); - for (std::string line; std::getline(vdf, line);) { - const size_t k = line.find("\"path\""); - if (k == std::string::npos) continue; - const size_t q0 = line.find('"', k + 6), q1 = q0 == std::string::npos ? q0 : line.find('"', q0 + 1); - if (q1 == std::string::npos) continue; - std::string v = line.substr(q0 + 1, q1 - q0 - 1), u; - for (size_t i = 0; i < v.size(); ++i) { if (v[i] == '\\' && i + 1 < v.size() && v[i + 1] == '\\') ++i; u += v[i]; } // (the file escapes its backslashes) - libs.emplace_back(u); - } - } - for (const auto& l : libs) { const auto c = l / "steamapps" / "common" / "Noita" / "data" / "data.wak"; if (std::filesystem::is_regular_file(c, ec)) { wakPath = c.string(); break; } } - } - } + if (wakPath.empty()) wakPath = find_game_wak(); if (wakPath.empty()) { MessageBoxA(nullptr, "Noita's data.wak was not found.\nStart noita_spelltest.exe with the path of data.wak (Noita/data/data.wak) as its argument.", "noita_spelltest", MB_OK | MB_ICONERROR); return 2; } std::printf("data.wak: %s%c", wakPath.c_str(), 10); stArgs = {argv[0], wakPath, "--spelltest"}; @@ -927,7 +949,24 @@ int main(int argc, char** argv) { } #endif try { - if (argc < 2) { std::fprintf(stderr, "usage: noita_gpu [--frames N --shot out.png]\n"); return 2; } + static std::vector mainArgs; static std::vector mainArgv; + { // the first argument: Noita's data.wak, or the game's folder (Noita/, holding data/data.wak, data/translations, data/audio, mods/), or none - the install is + // looked up (find_game_wak). From here on argv[1] is the wak's path; everything else (the loose data files, the FMOD banks and DLLs, the mods) is found from its folder. + std::string a1 = argc >= 2 ? argv[1] : "", wakFile; + std::error_code ec; + if (!a1.empty() && a1[0] != '-') { + const std::filesystem::path p1(a1); + if (std::filesystem::is_directory(p1, ec)) { for (const auto& c : {p1 / "data" / "data.wak", p1 / "data.wak"}) if (wakFile.empty() && std::filesystem::is_regular_file(c, ec)) wakFile = c.string(); } + else wakFile = a1; + if (wakFile.empty()) { std::fprintf(stderr, "%s: no data/data.wak in that folder\n", a1.c_str()); return 2; } + } else wakFile = find_game_wak(); + if (wakFile.empty()) { std::fprintf(stderr, "usage: noita_gpu [ | ] [--world 36 15] [--frames N --shot out.png] ...\n (Noita's install was not found: give its folder)\n"); return 2; } + mainArgs = {argv[0], wakFile}; + for (int i = (!a1.empty() && a1[0] != '-') ? 2 : 1; i < argc; ++i) mainArgs.push_back(argv[i]); + for (auto& s : mainArgs) mainArgv.push_back(s.data()); + argc = (int)mainArgv.size(); argv = mainArgv.data(); + std::printf("game: %s%c", std::filesystem::absolute(std::filesystem::path(wakFile)).parent_path().parent_path().string().c_str(), 10); + } int shotFrames = -1; double runSeconds = 0; // test: quit after this many seconds and report frames and ticks float gravityScale = 1.0f; // test: scales the gravity of characters @@ -1046,6 +1085,112 @@ int main(int argc, char** argv) { std::printf("profile: %zu persistent flags, %zu orbs found%c", ng::LuaHost::persistent_flags().size(), profileOrbs.size(), 10); } } + // ---- the menus (src/app/menu.h): the title screen, the pause menu, the game modes, the options, the release notes, the game over screen ---- + menu::Menu menuGame; + menu::Assets menuAssets; + menu::Settings settings; + std::vector gameModes; + std::vector enabledMods; + std::string releaseNotes, exeDirStr; + menu::RunStats runStats, bestStats; int totalDeaths = 0, totalWins = 0; + std::set placesVisited; + std::map> enemyStats; // the Progress screen's enemies: animal name -> (the player's kills of it, the player's deaths to it), save/stats/enemies.txt + std::set progressSeen; bool progressSeenKnown = false; // the flags (and animal_ kills) the Progress screen has shown: the others are "(new)" (save/progress_seen.txt) + std::vector progPerks, progActions, progEnemies; + std::vector allMods; // every folder of mods/ (the Mods screen), the enabled ones first in their load order + bool wantSwapchain = false, sessionWritten = false, menuForcedPause = false, continueRelaunch = false; + std::string continueArgs; // (save/run.txt: the last run started from the menu, for the title's Continue) + std::function menuWin; // (the ending: the run counts as a win in the session stats) + std::string sessionStamp; + { + char exe0[MAX_PATH]; GetModuleFileNameA(nullptr, exe0, MAX_PATH); + const std::filesystem::path exeDir = std::filesystem::path(exe0).parent_path(); + exeDirStr = exeDir.string(); + const std::filesystem::path saveDir = exeDir / "save"; + std::error_code ec; std::filesystem::create_directories(saveDir / "stats" / "sessions", ec); + settings.load((saveDir / "settings.txt").string()); + g_vsync = settings.vsync; + for (size_t k = 0; k < settings.keys.size() && k < 9; ++k) g_keys[k] = settings.keys[k].key; + { // release_notes.txt beside the executable (CMake writes it from the git log: "hash|date|subject" lines): grouped by date like the game's RELEASE NOTES + std::ifstream rn(exeDir / "release_notes.txt"); + std::string line, lastDate; + while (std::getline(rn, line)) { + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); + const size_t a = line.find('|'), b = a == std::string::npos ? a : line.find('|', a + 1); + if (b == std::string::npos) continue; + const std::string hash = line.substr(0, a), date = line.substr(a + 1, b - a - 1), subject = line.substr(b + 1); + if (date != lastDate) { releaseNotes += std::string(lastDate.empty() ? "" : "\n") + "#RELEASE NOTES - " + date + "\n\n"; lastDate = date; } + releaseNotes += "*" + hash + " " + subject + "\n"; + } + } + { // the bests and totals of our own runs (save/stats/sessions/*_stats.xml, the game's layout) + for (const auto& e : std::filesystem::directory_iterator(saveDir / "stats" / "sessions", ec)) { + const std::string n = e.path().filename().string(); + if (n.size() < 10 || n.compare(n.size() - 10, 10, "_stats.xml") != 0) continue; + std::ifstream sf(e.path(), std::ios::binary); std::string s((std::istreambuf_iterator(sf)), std::istreambuf_iterator()); + auto num = [&](const char* k) { const std::string key = std::string(" ") + k + "=\""; const size_t p0 = s.find(key); return p0 == std::string::npos ? 0.0 : std::atof(s.c_str() + p0 + key.size()); }; + bestStats.gold = std::max(bestStats.gold, (int)num("gold")); bestStats.playtime = std::max(bestStats.playtime, num("playtime")); bestStats.depth = std::max(bestStats.depth, (int)num("depth")); + bestStats.places = std::max(bestStats.places, (int)num("places_visited")); bestStats.kills = std::max(bestStats.kills, (int)num("enemies_killed")); + bestStats.maxhp = std::max(bestStats.maxhp, (int)num("hp")); bestStats.items = std::max(bestStats.items, (int)num("items")); + if (num("dead") > 0) ++totalDeaths; + if (num("win") > 0) ++totalWins; + } + std::ifstream ld(saveDir / "stats" / "last_daily_run_played.txt"); unsigned long v = 0; if (ld >> v) menuGame.last_daily_played = (uint32_t)v; + std::ifstream ef(saveDir / "stats" / "enemies.txt"); // " " per line + for (std::string line; std::getline(ef, line);) { std::istringstream is(line); std::string n; int k = 0, d = 0; if (is >> n >> k >> d) enemyStats[n] = {k, d}; } + std::ifstream sf(saveDir / "progress_seen.txt"); + if (sf) { progressSeenKnown = true; for (std::string line; std::getline(sf, line);) { while (!line.empty() && (line.back() == 13 || line.back() == ' ')) line.pop_back(); if (!line.empty()) progressSeen.insert(line); } } + } + { // the mods (the Mods screen): every mods//mod.xml; enabled and ordered by save/mod_config.xml (ours, written by the screen) or else the game's (read only) + std::vector> listed; // (id, enabled) in the config's order + { + std::filesystem::path cfg = saveDir / "mod_config.xml"; + PWSTR lowDir = nullptr; + if (!std::filesystem::exists(cfg, ec) && SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppDataLow, 0, nullptr, &lowDir)) && lowDir) { cfg = std::filesystem::path(lowDir) / "Nolla_Games_Noita" / "save00" / "mod_config.xml"; CoTaskMemFree(lowDir); } + std::ifstream cf(cfg); std::string s((std::istreambuf_iterator(cf)), std::istreambuf_iterator()); + for (size_t p0 = s.find("', p0); const std::string tag = s.substr(p0, p1 == std::string::npos ? std::string::npos : p1 - p0); + const size_t n0 = tag.find(" name=\""); if (n0 == std::string::npos) continue; + const size_t n1 = tag.find('"', n0 + 7); + listed.push_back({tag.substr(n0 + 7, n1 - n0 - 7), tag.find(" enabled=\"1\"") != std::string::npos}); + } + } + auto modOf = [&](const std::string& id, bool enabled) { + std::ifstream mf(gameDir / "mods" / id / "mod.xml"); if (!mf) return false; + std::string s((std::istreambuf_iterator(mf)), std::istreambuf_iterator()); + auto attr = [&](const char* k) { const std::string key = std::string(k) + "=\""; const size_t p0 = s.find(key); if (p0 == std::string::npos) return std::string(); const size_t p1 = s.find('"', p0 + key.size()); return s.substr(p0 + key.size(), p1 - p0 - key.size()); }; + allMods.push_back({id, attr("name"), attr("description"), enabled && attr("is_game_mode") != "1", attr("is_game_mode") == "1"}); + return true; + }; + for (const auto& [id, en] : listed) if (std::filesystem::is_regular_file(gameDir / "mods" / id / "init.lua", ec)) modOf(id, en); + std::vector rest; + for (const auto& e : std::filesystem::directory_iterator(gameDir / "mods", ec)) { + const std::string id = e.path().filename().string(); + bool have = false; for (const menu::ModInfo& m : allMods) have |= m.id == id; + if (!have && e.is_directory(ec) && std::filesystem::is_regular_file(e.path() / "init.lua", ec)) rest.push_back(id); + } + std::sort(rest.begin(), rest.end()); + for (const std::string& id : rest) modOf(id, false); + } + { // the game modes (006378a0): normal and the daily run, then every mod with is_game_mode="1" (mods//mod.xml, its ui_newgame_* fields) + gameModes.push_back({"normal", "$gamemode_normal", "$gamemode_normal_desc", "", "", false}); + gameModes.push_back({"dailyrun", "$gamemode_dailyrun", "$gamemode_dailyrun_desc", "", "", false}); + std::vector known; // the mods of the mod config in its order (the game lists only those it knows): ours (save/mod_config.xml, the Mods screen) or the game's + for (const menu::ModInfo& m : allMods) known.push_back(m.id); + std::stable_sort(known.begin(), known.end(), [](const std::string& a, const std::string& b) { return (a == "nightmare") > (b == "nightmare"); }); + for (const std::string& id0 : known) { + std::filesystem::directory_entry e(gameDir / "mods" / id0, ec); + if (!e.is_directory(ec)) continue; + std::ifstream mf(e.path() / "mod.xml"); if (!mf) continue; + std::string s((std::istreambuf_iterator(mf)), std::istreambuf_iterator()); + auto attr = [&](const char* k) { const std::string key = std::string(k) + "=\""; const size_t p0 = s.find(key); if (p0 == std::string::npos) return std::string(); const size_t p1 = s.find('"', p0 + key.size()); return s.substr(p0 + key.size(), p1 - p0 - key.size()); }; + if (attr("is_game_mode") != "1" || attr("ui_newgame_name").empty()) continue; // (a game mode shown in the menu names itself for it: the example mod does not) + const std::string id = e.path().filename().string(); + gameModes.push_back({id, attr("ui_newgame_name").empty() ? attr("name") : attr("ui_newgame_name"), attr("ui_newgame_description").empty() ? attr("description") : attr("ui_newgame_description"), attr("ui_newgame_gfx_banner_bg"), attr("ui_newgame_gfx_banner_fg"), true}); + } + } + menuGame.loose_icon = [&wak](const std::string& file) { return hud::load_icon(wak, file); }; + } ng::LuaHost::png_decoder() = [&wak](const std::string& file, int& w, int& h, std::vector& argb) { // ModImageMakeEditable, BiomeMapLoadImage std::vector png; ng::Image im; if (!wak.read(file, png) || !ng::decode_png(png.data(), png.size(), im)) return false; @@ -1066,7 +1211,9 @@ int main(int argc, char** argv) { std::filesystem::path modConfig; PWSTR lowDir = nullptr; if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppDataLow, 0, nullptr, &lowDir)) && lowDir) { modConfig = std::filesystem::path(lowDir) / "Nolla_Games_Noita" / "save00" / "mod_config.xml"; CoTaskMemFree(lowDir); } // (read only) + { std::error_code ec; const std::filesystem::path ours = std::filesystem::path(exeDirStr) / "save" / "mod_config.xml"; if (std::filesystem::exists(ours, ec)) modConfig = ours; } // (the Mods screen's list, the same layout) const auto& ml = modLoader->discover(gameDir, modConfig); + enabledMods = ml; if (!ml.empty()) { std::printf("mods:"); for (const std::string& m : ml) std::printf(" %s", m.c_str()); std::printf("%c", 10); } ng::register_biome_config_natives(modLoader->host()); modLoader->run_init(); @@ -1739,7 +1886,7 @@ int main(int argc, char** argv) { rigid.set_physics(&physSys); } g.upload(g.ents, entStateV.data(), entStateV.size() * sizeof(ng::EntityState)); - struct CtrlBlock { int32_t inp[4]; int32_t inp2[4]; int32_t inp3[4]; float outp[4]; float outp2[4]; float view[4]; float hand0[4]; int32_t hand1[4]; float hand2[4]; int32_t burnN[4]; int32_t burnEv[1024]; int32_t reactN[4]; int32_t reactEv[256]; }; // burnN / burnEv: pixels burnt away this frame (rigid_burn.comp); view: camera shift of the last tick, interpolation alpha, gravity scale; hand: the wand in the hand + struct CtrlBlock { int32_t inp[4]; int32_t inp2[4]; int32_t inp3[4]; float outp[4]; float outp2[4]; float view[4]; float hand0[4]; int32_t hand1[4]; float hand2[4]; int32_t burnN[4]; int32_t burnEv[1024]; int32_t reactN[4]; int32_t reactEv[256]; int32_t extra[4]; }; // extra.x: a dead player stays dead (the menus' game over). burnN / burnEv: pixels burnt away this frame (rigid_burn.comp); view: camera shift of the last tick, interpolation alpha, gravity scale; hand: the wand in the hand g.hitLiquid = g.make_buffer(sizeof(ng::HitLiquidTable), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, hostVis); ng::HitLiquidTable* hitLiquidPtr = nullptr; VK(vkMapMemory(g.dev, g.hitLiquid.mem, 0, VK_WHOLE_SIZE, 0, (void**)&hitLiquidPtr)); @@ -1858,6 +2005,7 @@ int main(int argc, char** argv) { double x0 = 0, y0 = 0, pop = 1.5, popT = 0, riseT = 0, fadeT = 0.3125, moveT = 0; bool crit = false; }; std::vector critFrame(kMaxEnts, -1000); double itemRecoil = 0.0; // AbilityComponent mItemRecoil of the held wand + double itemPushBack = 0.0; // (00b51670: the held item's push back along the aim out of a wall, <= 0 px; smoothed by the shot-origin block, drawn by fx_hand.comp) struct SpellDummy { int id = 0; double x = 0, y = 0; int dead = -1; float hpSeen = -1.0f; double burst = 0.0; int burstStart = 0, lastHit = -1000; }; std::vector spellDummies; // the spell test room's dummies // the frame of the last critical hit on a slot (GPU event -9): its damage number gets " - CRIT" auto dmgPop = [](long long v) { static const long long lim[9] = {100, 500, 1000, 5000, 10000, 50000, 100000, 500000, 1000000}; double k = 1.5; if (v >= 0) for (int i = 0; i < 9; ++i) if (v >= lim[i]) k = 2.0 + 0.5 * i; return (k - 1.5) / 4.5 * ((k - 1.5) / 4.5) * 3.5 + 1.5; }; @@ -1987,6 +2135,7 @@ int main(int argc, char** argv) { }; // ---- flasks: items of the quick inventory (slots 5-8) holding one material; LMB pours it out, RMB throws the flask, which breaks on a hard impact ---- int activeItem = -1; + bool potionFirePrev = false; bool rmbPrev = false; auto flaskContents = [&](int id, std::string& mat, double& count) -> int { // the MaterialInventoryComponent of a flask (0 = none) mat.clear(); count = 0; @@ -1998,13 +2147,35 @@ int main(int argc, char** argv) { return v.front(); }; auto materialColor = [&](const std::string& n) -> uint32_t { const ng::Material* mm = db.find(n); return mm ? 0xFF000000u | ((mm->graphics_color ? mm->graphics_color : mm->wang_color) & 0xFFFFFFu) : 0xFF4080C0u; }; + auto flaskFullness = [&](int id, double total) -> float { + // 00b5e390: capacity comes from MaterialSuckerComponent; without one, + // use the largest material count, rather than a hard-coded flask size. + auto sucker = rt->components(id, "MaterialSuckerComponent", "", true); + double capacity = 0; + if (!sucker.empty()) { + const auto& a = rt->comp(sucker.front())->attrs; + auto it = a.find("barrel_size"); + capacity = it == a.end() ? 1000 : std::atof(it->second.c_str()); + } else { + auto inventory = rt->components(id, "MaterialInventoryComponent", "", true); + if (!inventory.empty()) for (const auto& p : ng::mix_get(*rt, inventory.front())) capacity = std::max(capacity, p.n); + } + return capacity > 0 ? (float)(total / capacity) : 0.0f; + }; hud::Icon potionUiBase, potionHandBase; ng::load_first_frame(wak, "data/items_gfx/potion.png", "", potionHandBase.px, potionHandBase.w, potionHandBase.h); potionUiBase = hud::load_icon(wak, "data/ui_gfx/items/potion.png"); - std::map potionTint[2]; // the grey flask sprites multiplied by the colour of the liquid (0: hand sprite, 1: inventory icon) - auto flaskIcon = [&](const std::string& mat, bool ui) -> const hud::Icon* { - const uint32_t col = mat.empty() ? 0xFFB0B0B0u : materialColor(mat); - auto& cache = potionTint[ui ? 1 : 0]; + std::map potionTint; // held sprite tint, independent of inventory liquid levels + std::map, hud::Icon> potionFill; + auto flaskIcon = [&](const std::string& mat, bool ui, float fraction = 1.0f) -> const hud::Icon* { + const uint32_t col = mat.empty() ? (ui ? 0xFFFFFFFFu : 0xFFB0B0B0u) : materialColor(mat); + if (ui) { + const auto key = std::make_pair(col, hud::potion_fill_row(potionUiBase.h, fraction)); + auto it = potionFill.find(key); + if (it == potionFill.end()) it = potionFill.emplace(key, hud::potion_icon(potionUiBase, col, key.second)).first; + return it->second.ok() ? &it->second : nullptr; + } + auto& cache = potionTint; auto it = cache.find(col); if (it == cache.end()) { hud::Icon ic = ui ? potionUiBase : potionHandBase; @@ -3200,7 +3371,7 @@ int main(int argc, char** argv) { } std::printf("ConvertMaterialEverywhere: %s -> %s%c", fname.c_str(), tname.c_str(), 10); }; - rt->on_screenshake = [&](double amount) { const uint32_t v = (uint32_t)std::max(0.0, amount * 1500.0); queueUp(g.fxLights, 4, &v, 4); }; // GameScreenshake: the explosions' camera shake (camera_shake x 1500 milli-cells) // GamePlaySound (the bank file only names the bank: every bank is loaded) + rt->on_screenshake = [&](double amount) { const uint32_t v = (uint32_t)std::max(0.0, amount * 1500.0 * settings.screenshake); queueUp(g.fxLights, 4, &v, 4); }; // (x the Options' screen shake intensity) // GameScreenshake: the explosions' camera shake (camera_shake x 1500 milli-cells) // GamePlaySound (the bank file only names the bank: every bank is loaded) rt->on_entity_sound = [&](int id, const std::string& name) { audioComponentPlay(id, name); }; rt->on_entity_sound_loop = [&](int id, const std::string& tag, double i1, double i2) { for (int lc : rt->components(id, "AudioLoopComponent", tag, false)) audioLoopPlay(lc, (float)i1, (float)i2); @@ -3960,7 +4131,8 @@ int main(int argc, char** argv) { for (int i = 0; i < entN; ++i) makeRigid(i); // ---- creature deaths: the death scripts (gold, loot), the corpse as rigid bodies (the ragdoll parts), blood has already burst on the GPU ---- std::map ragdollIcons; - auto onCreatureDeath = [&](int slot) { + int playerCorpseSlot = -1; // the torso part of the player's ragdoll (00abb7d0: the "player_ragdoll" part with the FogOfWarRadiusComponent; RAGDOLL_FX 9 PLAYER_RAGDOLL_CAMERA: the camera follows it) + auto onCreatureDeath = [&](int slot, bool playerBody = false) { // (playerBody: the player's death of the menus' game over - the ragdoll of 00abb7d0 without the death scripts or the entity's end) const ng::EntityState es = entReadPtr[slot]; const int types = es.ex3[3]; const bool gib = (types & 16) != 0; @@ -3968,7 +4140,7 @@ int main(int argc, char** argv) { if (audioComponentPlayFn) audioComponentPlayFn(id, ((types & (1 | 2 | 8)) == 0 && (types & (4 | 32))) ? "death_alt" : "death"); // 00abb7d0: "death", "death_alt" for fire, drowning (and radioactive, poison, the player) ng::RtEntity* re = rt ? rt->ent(id) : nullptr; if (!re) return; - if (const auto nh = npcHold.find(id); nh != npcHold.end()) { // ItemPickUpperComponent drop_items_on_death: the held item falls where the NPC died + if (const auto nh = npcHold.find(id); nh != npcHold.end() && !playerBody) { // ItemPickUpperComponent drop_items_on_death: the held item falls where the NPC died bool dropIt = true; for (int uc : rt->components(id, "ItemPickUpperComponent", "", true)) if (rt->comp(uc)->attrs["drop_items_on_death"] == "0") dropIt = false; if (dropIt) npcDropItems(id, es.pos[0], es.pos[1]); @@ -3977,11 +4149,12 @@ int main(int argc, char** argv) { const ng::EntityDef* def = entBuilder.db().get(re->file); re->x = es.pos[0]; re->y = es.pos[1]; // the scripts drop their loot where the creature died tossing = true; - rt->run_death_scripts(id, types & 15, slotAttacker(es, 30, ctrlPtr->inp2[0])); // the killer: the entity that damaged it last + if (!playerBody) rt->run_death_scripts(id, types & 15, slotAttacker(es, 30, ctrlPtr->inp2[0])); // the killer: the entity that damaged it last (the player: init.lua's OnPlayerDied) tossing = false; const ng::EntityComponent* dm = nullptr; if (def) { auto v = def->all("DamageModelComponent"); if (!v.empty()) dm = v.front(); } const std::string listFile = dm ? dm->get("ragdoll_filenames_file") : std::string(); + const double ragOffY = dm ? std::atof(dm->get("ragdoll_offset_y", "0").c_str()) : 0.0; // (00abb7d0 -> LoadRagdoll 00be8240: the parts are laid out about the origin moved by it; the player: -5) std::string fxForced = dm ? dm->get("ragdoll_fx_forced") : std::string(); std::string convMat = dm ? dm->get("ragdoll_material") : std::string(); for (int dc : rt->components(id, "DamageModelComponent", "", true)) { // a hit effect (CONVERT_RAGDOLL_TO_MATERIAL) may have changed them @@ -4011,6 +4184,28 @@ int main(int argc, char** argv) { for (uint8_t ch : raw) { if (ch == '\n' || ch == '\r') { if (!cur.empty()) parts.push_back(cur); cur.clear(); } else cur += (char)ch; } if (!cur.empty()) parts.push_back(cur); } + struct PartPos { double x, y; int ox, oy; }; std::map partAt; // a part of its own place: a child's sprite + if (dm && dm->get("ragdollify_child_entity_sprites", "0") == "1" && !parts.empty()) { // 00abb7d0: of the children's enabled SpriteComponents the one with the highest z_index (the player's arm, 0.59) joins the parts where the child is + double bestZ = -1e9; + for (int ch : rt->children(id)) { + const ng::RtEntity* ce = rt->ent(ch); if (!ce || !ce->alive) continue; + for (int sc : rt->components(ch, "SpriteComponent", "", false)) { + auto& sa = rt->comp(sc)->attrs; + if (!rt->comp(sc)->enabled || sa["never_ragdollify_on_death"] == "1") continue; + const double z = std::atof(sa["z_index"].c_str()); if (z <= bestZ) continue; + std::string img = sa["image_file"]; + if (img.size() > 4 && img.compare(img.size() - 4, 4, ".xml") == 0) { // the sprite's sheet + std::vector xr; if (!wak.read(img, xr)) continue; + const std::string xs(xr.begin(), xr.end()); const size_t f0 = xs.find("filename=\""); if (f0 == std::string::npos) continue; + img = xs.substr(f0 + 10, xs.find('"', f0 + 10) - f0 - 10); + } + if (img.empty()) continue; + bestZ = z; + if (!partAt.empty()) { parts.erase(std::find(parts.begin(), parts.end(), partAt.begin()->first)); partAt.clear(); } + parts.push_back(img); partAt[img] = {ce->x, ce->y, (int)std::atof(sa["offset_x"].c_str()), (int)std::atof(sa["offset_y"].c_str())}; + } + } + } const ng::SpriteInstance si0 = entBuilder.layer().inst[sprInst]; const bool flip = es.st[1] < 0; double hx = es.aux[2] * 0.001, hy = es.aux[3] * 0.001; @@ -4052,7 +4247,10 @@ int main(int argc, char** argv) { if (opaque < 2) continue; std::vector px = ic.px; if (flip) for (int y = 0; y < ic.h; ++y) std::reverse(px.begin() + (size_t)y * ic.w, px.begin() + (size_t)(y + 1) * ic.w); - const int ox = flip ? ic.w - 1 - si0.ox : si0.ox; + const auto own = partAt.find(pf); // (a child's sprite: its own offset and place) + const int ox0 = own != partAt.end() ? own->second.ox : si0.ox, oy = own != partAt.end() ? own->second.oy : si0.oy; + const double partX = own != partAt.end() ? own->second.x : es.pos[0], partY = own != partAt.end() ? own->second.y : es.pos[1] + ragOffY; + const int ox = flip ? ic.w - 1 - ox0 : ox0; const int tex = entBuilder.add_texels(pf + (flip ? "|f" : ""), px); // retire the oldest parts when there are too many while (corpsePartCount >= kMaxCorpseParts && !corpses.empty()) { // only when the body table is nearly full does the oldest whole corpse go (their joints refer to each other) @@ -4066,9 +4264,9 @@ int main(int argc, char** argv) { if (reuse < 0 && (L.ents.size() >= (size_t)kMaxEnts || L.inst.size() >= (size_t)kMaxInst)) continue; if (L.texels.size() + px.size() > entTexCapacity) continue; int instIdx = -1; - const int ns = entBuilder.add_body_entity(tex, ic.w, ic.h, ox, si0.oy, es.pos[0], es.pos[1], reuse, &instIdx); + const int ns = entBuilder.add_body_entity(tex, ic.w, ic.h, ox, oy, partX, partY, reuse, &instIdx); slotToId[ns] = 0; deathDone[ns] = 0; propDone[ns] = 0; - L.ents[ns].ex10[0] = L.ents[ns].ex10[2] = es.pos[0]; L.ents[ns].ex10[1] = L.ents[ns].ex10[3] = es.pos[1]; + L.ents[ns].ex10[0] = L.ents[ns].ex10[2] = (float)partX; L.ents[ns].ex10[1] = L.ents[ns].ex10[3] = (float)partY; const double partMass = opaque / 100.0 * ragDensity; if (es.ex3[0] > 0 && partsBleed && partMass >= 0.009999999776) { // 00abb7d0: the blood of the creature becomes a finite reserve of this part, emitted when the part is damaged L.ents[ns].ex7[1] = float(ng::ragdoll_hit_liquid_reserve(float(frand()), currentGore, 2.0f, partMass, totalOpaque / 100.0 * ragDensity, int(bloodAbs))); @@ -4078,7 +4276,7 @@ int main(int argc, char** argv) { { // the emitter of the piece (00abb7d0): the direction is the part's centre relative to the creature's origin, the speed scales with the part's mass double sx = 0, sy = 0; int n = 0; for (int yy = 0; yy < ic.h; ++yy) for (int xx = 0; xx < ic.w; ++xx) if ((px[(size_t)yy * ic.w + xx] >> 24) >= 128) { sx += xx; sy += yy; ++n; } - double dx = n ? sx / n - ox : 0.0, dy = n ? sy / n - si0.oy : 0.0; + double dx = n ? sx / n - ox : 0.0, dy = n ? sy / n - oy : 0.0; const double dlen = std::hypot(dx, dy); if (dlen > 1e-4) { dx /= dlen; dy /= dlen; } else { dx = dy = 0.0; } const double mass = partMass; @@ -4143,8 +4341,10 @@ int main(int argc, char** argv) { corpsePartCount += slots.size(); if (std::getenv("NG_BLOODDBG")) std::printf("corpse of slot %d: %zu parts%c", slot, slots.size(), 10); corpses.push_back(std::move(slots)); + if (playerBody && !made.empty()) playerCorpseSlot = made.front().slot; // (the first file of filenames.txt is the torso: the "player_ragdoll" part) + if (playerBody && std::getenv("NG_KILLPLAYER")) { std::printf("frame %d: the player's ragdoll: %zu parts of %zu files -", ctrlPtr->inp2[0], made.size(), parts.size()); for (const std::string& pf : parts) std::printf(" %s", pf.c_str() + pf.find_last_of('/') + 1); std::printf("%c", 10); } } - rt->kill(id); + if (!playerBody) rt->kill(id); }; // ---- burning bodies fall apart (docs/FIRE_FRACTURE_AND_LIQUIDS.md): rigid_burn.comp burns the pixels and reports the ones that burnt away (age > fire_hp on every 32nd frame); // here they are removed from the image, the rest is partitioned into 4-connected islands (left, up, right, down; singletons vanish), every island becomes a body of its own on @@ -6350,6 +6550,7 @@ int main(int argc, char** argv) { // (gravity_x is not simulated by our cosmetic particles) const ng::Material* m = db.find(mat); const uint32_t rgb = color ? (color & 0xFFFFFFu) : (m ? matColor(m->index) : 0xFFFFFFu); + if (settings.cosmetic_particles < 1.0f) { const double c = n * settings.cosmetic_particles; n = (int)c + (frand() < c - (int)c ? 1 : 0); } // (the Options' cosmetic particle coefficient) for (int k = 0; k < std::min(n, 4096); ++k) { const double ux = randomize ? (frand() * 2.0 - 1.0) * std::fabs(vx) : vx, uy = randomize ? (frand() * 2.0 - 1.0) * std::fabs(vy) : vy; emitPart(x, y, ux / 60.0, uy / 60.0, (lmin + (lmax - lmin) * frand()) * 60.0, gy / 3600.0, rgb, 4, m ? m->index : 0, collide, force); @@ -6426,7 +6627,7 @@ int main(int argc, char** argv) { if (invp) { for (int& w : invp->wands) if (w == item) w = 0; for (int& w : invp->items) if (w == item) w = 0; for (int& w : invp->full) if (w == item) w = 0; } rt->kill(item); }; - S.pick_up_item = [&](int who, int item, bool) { rt->pick(item, who); }; + S.pick_up_item = [&](int who, int item, bool) { rt->pick(item, who); if (who == rt->player()) ++runStats.items; }; S.drop_items = [&](int owner, int mode) { // 0 / 1: everything into the world at the owner; 2: destroyed std::vector items = S.inventory_items(owner); double x = 0, y = 0; entityPos(owner, x, y); @@ -6496,7 +6697,7 @@ int main(int argc, char** argv) { done = true; deathTimer = 150; music.gameOver = true; // (+0xdd of the music system: no more music events) if (rt) if (const ng::RtEntity* pe = rt->ent(rt->player())) audio.play("event_cues/game_over/create", (float)pe->x, (float)pe->y); }; - S.ending = [&](int which) { rt->print("", which == 2 ? "$log_ending2" : "$log_ending", true); }; + S.ending = [&](int which) { rt->print("", which == 2 ? "$log_ending2" : "$log_ending", true); if (menuWin) menuWin(); }; S.set_spawn_location = [&](double x, double y) { const int32_t sp[2] = {(int32_t)std::lround(x), (int32_t)std::lround(y)}; entReadPtr[0].x[2] = sp[0]; entReadPtr[0].x[3] = sp[1]; patchEntity(0, offsetof(ng::EntityState, x) + 8, sp, 8); }; S.biome_map_load = [&](const std::string& file, const std::string& scenes) { // the map and pixel scenes of the areas generated from now on (the world already made stays) ng::BiomeMap::map_file_override() = file; ng::BiomeMap::pixel_scenes_file() = scenes; @@ -6534,6 +6735,130 @@ int main(int argc, char** argv) { } rigidDebug.append(sampleFrame, std::move(rows)); }; + auto mcBuild = []() -> std::string { +#ifdef NG_BUILD + return NG_BUILD; +#else + return "dev"; +#endif + }; + const bool menuEnabled = (!scripted || std::getenv("NG_MENU_TEST")) && !spellTest && !std::getenv("NG_NOMENU"); // (NG_MENU_TEST: the menus in a scripted run; NG_MENU_SCREEN=title|pause|newgame|options|notes|credits|gameover|stats opens one) + const std::string runModeName = std::getenv("NG_RUNMODE") ? std::getenv("NG_RUNMODE") : std::string("normal"); + int menuClickSeen = g_in.lmb_down_n; + g_menus = menuEnabled; + if (menuEnabled && !std::getenv("NG_MENU_SKIP")) { // the title screen; Continue takes up the last run the menu started (save/run.txt + run.world), by a relaunch + menuGame.open(menu::Menu::Screen::Title); + std::ifstream rf(std::filesystem::path(exeDirStr) / "save" / "run.txt"); + if (std::getline(rf, continueArgs) && !continueArgs.empty()) continueRelaunch = true; + } else menuGame.set_in_game(true); + if (const char* ms = std::getenv("NG_MENU_SCREEN")) { + const std::string s = ms; using Sc = menu::Menu::Screen; + if (s == "pause") { menuGame.set_in_game(true); menuGame.open(Sc::Pause); } else if (s == "newgame") menuGame.open(Sc::NewGame); else if (s == "options") menuGame.open(Sc::Options); + else if (s == "notes") menuGame.open(Sc::ReleaseNotes); else if (s == "credits") menuGame.open(Sc::Credits); else if (s == "gameover") menuGame.game_over("explosion"); + else if (s == "stats") { menuGame.game_over("explosion"); menuGame.open(Sc::GameOverStats); } else if (s == "mods") menuGame.open(Sc::Mods); else if (s == "progress") menuGame.open(Sc::Progress); + if (s == "newgame") menuGame.daily.fetch(); + } + menuAssets.load(wak); + if (std::getenv("NG_MENU_TEST")) std::printf("menu assets: logo %dx%d help %dx%d game_over %dx%d sky %dx%d layer1 %dx%d p9 %dx%d%c", menuAssets.logo.w, menuAssets.logo.h, menuAssets.help.w, menuAssets.help.h, menuAssets.gameOver.w, menuAssets.gameOver.h, menuAssets.skyW, menuAssets.skyH, menuAssets.layer1.w, menuAssets.layer1.h, menuAssets.p9.w, menuAssets.p9.h, 10); + auto animalName = [](const std::string& file) { const size_t a = file.find_last_of('/'), b = file.rfind(".xml"); return file.substr(a == std::string::npos ? 0 : a + 1, b == std::string::npos ? std::string::npos : b - (a == std::string::npos ? 0 : a + 1)); }; // data/entities/animals/sheep.xml -> sheep (the animal icons' names) + auto saveEnemyStats = [&]() { std::ofstream f(std::filesystem::path(exeDirStr) / "save" / "stats" / "enemies.txt"); for (const auto& [n, kd] : enemyStats) f << n << ' ' << kd.first << ' ' << kd.second << '\n'; }; + if (rt) rt->on_killed = [&, prevKilledMenu = rt->on_killed](int id) { // the run's "enemies slain": an enemy whose last attacker (ex8.w, within 5 s) was the player + if (const ng::RtEntity* re = rt->ent(id); re && re->gpu > 0 && re->gpu < kMaxEnts && rt->has_tag(id, "enemy")) { + const int w = entReadPtr[re->gpu].ex8[3]; + if ((w & 4095) == 1 && ((frame & 0xFFFFF) - ((w >> 12) & 0xFFFFF)) < 300) { ++runStats.kills; ++enemyStats[animalName(re->file)].first; saveEnemyStats(); } // (the Progress screen's "Kills") + } + if (prevKilledMenu) prevKilledMenu(id); + }; + { + char ts[32]; const std::time_t now = std::time(nullptr); std::tm tmv{}; localtime_s(&tmv, &now); std::strftime(ts, sizeof ts, "%Y%m%d-%H%M%S", &tmv); sessionStamp = ts; + } + auto buildProgress = [&]() { // the Progress screen's lists (006dfe40): the perks of perk_list.lua, the spells of gun_actions.lua, the enemies of animal_icons/_list.txt + const auto& flags = ng::LuaHost::persistent_flags(); + auto lower = [](std::string s) { for (char& ch : s) ch = (char)std::tolower((unsigned char)ch); return s; }; + auto isNew = [&](const std::string& key) { return progressSeenKnown && !progressSeen.count(key); }; + progPerks.clear(); progActions.clear(); progEnemies.clear(); + std::vector raw; + if (wak.read("data/scripts/perks/perk_list.lua", raw)) { // id = "X", ui_name = "$perk_x", ui_description = "$perkdesc_x", ui_icon = "data/ui_gfx/perk_icons/x.png" per entry + const std::string s(raw.begin(), raw.end()); + auto field = [&](size_t from, size_t to, const char* k) { const std::string key = std::string(k) + " = \""; const size_t p0 = s.find(key, from); if (p0 == std::string::npos || p0 >= to) return std::string(); const size_t p1 = s.find('"', p0 + key.size()); return s.substr(p0 + key.size(), p1 - p0 - key.size()); }; + for (size_t p = s.find("id = \""); p != std::string::npos;) { + const size_t q = s.find("id = \"", p + 6), end = q == std::string::npos ? s.size() : q; + menu::ProgressItem it; it.id = field(p, end, "id"); it.name = field(p, end, "ui_name"); it.desc = field(p, end, "ui_description"); it.icon = field(p, end, "ui_icon"); + const std::string flag = "perk_picked_" + lower(it.id); + it.known = flags.count(flag) != 0; it.is_new = it.known && isNew(flag); + if (!it.id.empty()) progPerks.push_back(it); + p = q; + } + } + if (gun) for (const ng::GunRuntime::ActionInfo* ai : gun->all_actions()) { + menu::ProgressItem it; it.id = ai->id; it.name = ai->name; it.desc = ai->description; it.icon = ai->sprite; + const std::string flag = "action_" + lower(ai->id); + it.known = flags.count(flag) != 0; it.is_new = it.known && isNew(flag); + progActions.push_back(it); + } + if (wak.read("data/ui_gfx/animal_icons/_list.txt", raw)) { + std::string cur; raw.push_back('\n'); + for (uint8_t ch : raw) { + if (ch == '\n' || ch == '\r') { if (!cur.empty()) { menu::ProgressItem it; it.id = cur; it.name = "$animal_" + cur; it.icon = "data/ui_gfx/animal_icons/" + cur + ".png"; const auto st = enemyStats.find(cur); if (st != enemyStats.end()) { it.kills = st->second.first; it.killed_by = st->second.second; } it.known = it.kills > 0; it.is_new = it.known && isNew("animal_" + cur); progEnemies.push_back(it); } cur.clear(); } + else cur += (char)ch; + } + } + }; + auto progressSeenNow = [&]() { // the screen was seen: nothing is new until another flag comes + progressSeen.clear(); progressSeenKnown = true; + for (const std::string& f : ng::LuaHost::persistent_flags()) progressSeen.insert(f); + for (const auto& [n, kd] : enemyStats) if (kd.first > 0) progressSeen.insert("animal_" + n); + std::ofstream f(std::filesystem::path(exeDirStr) / "save" / "progress_seen.txt"); for (const std::string& s : progressSeen) f << s << '\n'; + }; + if (!progressSeenKnown) progressSeenNow(); // (the first start: the profile's flags are not news) + auto saveMods = [&]() { // save/mod_config.xml in the game's layout: the Mods screen's list and order + std::ofstream f(std::filesystem::path(exeDirStr) / "save" / "mod_config.xml"); + f << "\n"; + for (const menu::ModInfo& m : allMods) f << " \n"; + f << "\n"; + }; + auto writeSession = [&](bool dead, const std::string& cause, bool win = false) { // save/stats/sessions/_stats.xml, the game's layout (006e51e0 reads the same fields for its stats) + if (sessionWritten && !dead && !win) return; + sessionWritten = true; + std::ofstream f(std::filesystem::path(exeDirStr) / "save" / "stats" / "sessions" / (sessionStamp + "_stats.xml"), std::ios::binary); + char tstr[32]; const int tsec = (int)runStats.playtime; std::snprintf(tstr, sizeof tstr, "%d:%02d:%02d", tsec / 3600, (tsec / 60) % 60, tsec % 60); + f << "\n \n \n\n"; + }; + auto applySettings = [&]() { // the Options: vsync (the swapchain again), the window mode (borderless over the monitor), the volumes, the keys + if (g_vsync != settings.vsync) { g_vsync = settings.vsync; wantSwapchain = true; } + for (size_t k = 0; k < settings.keys.size() && k < 9; ++k) g_keys[k] = settings.keys[k].key; + audio.set_gains(settings.sfx, settings.music); + static int appliedMode = 0; + if (appliedMode != settings.window_mode) { + appliedMode = settings.window_mode; + if (settings.window_mode == 1) { + MONITORINFO mi{sizeof mi}; GetMonitorInfoA(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &mi); + SetWindowLongPtrA(hwnd, GWL_STYLE, WS_POPUP | WS_VISIBLE); + SetWindowPos(hwnd, HWND_TOP, mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right - mi.rcMonitor.left, mi.rcMonitor.bottom - mi.rcMonitor.top, SWP_FRAMECHANGED | SWP_SHOWWINDOW); + } else { + SetWindowLongPtrA(hwnd, GWL_STYLE, WS_OVERLAPPEDWINDOW | WS_VISIBLE); + RECT r{0, 0, kStartW, kStartH}; AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, FALSE); + SetWindowPos(hwnd, HWND_TOP, 100, 100, r.right - r.left, r.bottom - r.top, SWP_FRAMECHANGED | SWP_SHOWWINDOW); + } + } + }; + menuWin = [&]() { writeSession(false, "", true); ++totalWins; }; + auto relaunchRun = [&](const std::string& seedStr, const std::string& modeStr, int daily) { // the executable again with a run (its seed, its game mode as the mods / the daily flags); this one quits + const std::string cmd = std::string("\"") + exeDirStr + "\\noita_gpu.exe\" \"" + std::string(argv[1]) + "\" --world " + seedStr + " 36 15 --world-file \"" + exeDirStr + "\\save\\run.world\""; + SetEnvironmentVariableA("NG_MENU_SKIP", "1"); SetEnvironmentVariableA("NG_RUNMODE", modeStr.c_str()); + SetEnvironmentVariableA("NG_DAILY", daily == 1 ? "1" : daily == 2 ? "2" : nullptr); + bool modMode = false; for (const menu::GameMode& gm : gameModes) if (gm.id == modeStr && gm.mod) modMode = true; + SetEnvironmentVariableA("NG_MODS", modMode ? modeStr.c_str() : nullptr); + { RECT wr{}, cr{}; GetWindowRect(hwnd, &wr); GetClientRect(hwnd, &cr); SetEnvironmentVariableA("NG_WIN", (std::to_string(cr.right - cr.left) + "x" + std::to_string(cr.bottom - cr.top)).c_str()); SetEnvironmentVariableA("NG_WINPOS", (std::to_string(wr.left) + "," + std::to_string(wr.top)).c_str()); } // (the new process takes this window's size and place: make_window) + STARTUPINFOA si{}; si.cb = sizeof si; PROCESS_INFORMATION pi{}; + std::vector cl(cmd.begin(), cmd.end()); cl.push_back(0); + if (CreateProcessA(nullptr, cl.data(), nullptr, nullptr, FALSE, 0, nullptr, exeDirStr.c_str(), &si, &pi)) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); g_in.quit = true; return true; } + std::printf("could not start %s%c", cmd.c_str(), 10); + return false; + }; + applySettings(); while (!g_in.quit) { perf.begin(frame); PERFMARK("window messages"); @@ -6541,7 +6866,8 @@ int main(int argc, char** argv) { while (PeekMessageA(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessageA(&msg); } if (g_in.quit) break; if (IsIconic(hwnd)) { Sleep(30); continue; } - if (g_in.cw > 0 && (g_in.cw != (int)g.extent.width || g_in.ch != (int)g.extent.height) && !scripted) { // the window was resized + if ((g_in.cw > 0 && (g_in.cw != (int)g.extent.width || g_in.ch != (int)g.extent.height) && !scripted) || wantSwapchain) { // the window was resized (or the Options changed the present mode) + wantSwapchain = false; VK(vkDeviceWaitIdle(g.dev)); create_swapchain(g); curW = (int)g.extent.width; curH = (int)g.extent.height; @@ -6760,6 +7086,8 @@ int main(int argc, char** argv) { if (g_gifOpen) tickAcc = 0; // the clip editor stops the game completely: no tick at all (cells, entities, projectiles, scripts, music, camera, fog, time of day) const bool doTick = !g_gifOpen && (scripted || frame == 0 || tickAcc >= tickSec * 0.999); if (doTick && !scripted && frame > 0) tickAcc = std::min(tickAcc - tickSec, tickSec); // at most one tick of backlog: a slow machine slows the game down + if (menuGame.active() && !menuGame.over_game() && !paused) { paused = true; menuForcedPause = true; } else if ((!menuGame.active() || menuGame.over_game()) && menuForcedPause) { paused = false; menuForcedPause = false; } // a menu pauses like P (the game over plays over the running world) + if (paused && worldMode) { const uint32_t noShake = 0; queueUp(g.fxLights, 4, &noShake, 4); } // (the camera shake decays only with the ticks: a paused frame would re-roll it every frame) const bool simOff = paused || !doTick || popWand != 0; // (the new-wand choice freezes the game; the clip editor too, through doTick) alphaI = scripted ? 1.0 : std::clamp(tickAcc / tickSec, 0.0, 1.0); // how far we are between the last tick and the next if (const char* renderAlpha = std::getenv("NG_RENDER_ALPHA")) alphaI = std::clamp(std::atof(renderAlpha), 0.0, 1.0); // reproduce live interpolation in frame-limited tests @@ -6948,6 +7276,8 @@ int main(int argc, char** argv) { if (playerPoly.active) wantFire = false; // the body has no wands or flasks of its own (no Inventory2Component) } aimXc = aimX; aimYc = aimY; + ctrlPtr->extra[0] = menuEnabled && !spellTest ? 1 : 0; // (entities.comp: the player's death is final - the game over screen - instead of the respawn) + ctrlPtr->extra[1] = (int)std::lround(settings.brightness * 10000.0f); ctrlPtr->extra[2] = (int)std::lround(settings.contrast * 10000.0f); ctrlPtr->extra[3] = (int)std::lround(settings.gamma * 10000.0f); // (render.comp: the Options' picture) ctrlPtr->inp3[0] = aimX; ctrlPtr->inp3[1] = aimY; ctrlPtr->inp3[2] = (playMode && !scripted && !simOff && GetForegroundWindow() == hwnd && ((GetAsyncKeyState(VK_DOWN) & 0x8000) || (GetAsyncKeyState('S') & 0x8000))) ? 1 : (playMode && scripted && std::getenv("NG_DOWN") && frame >= std::atoi(std::getenv("NG_DOWN")) && (!std::getenv("NG_DOWNEND") || frame < std::atoi(std::getenv("NG_DOWNEND"))) ? 1 : 0); ctrlPtr->inp3[3] = -1; // the wand fires from the CPU side now ctrlPtr->inp[0] = ix; ctrlPtr->inp[1] = jump; ctrlPtr->inp[2] = playMode ? attack : 0; ctrlPtr->inp[3] = entN; ctrlPtr->inp2[0] = frame; ctrlPtr->inp2[1] = hasPlayer ? 1 : 0; @@ -7043,15 +7373,23 @@ int main(int argc, char** argv) { int f = frame; for (const ScrollPhase& ph : scrollPhases) { if (f < ph.frames) { shiftX = ph.dx; shiftY = ph.dy; break; } f -= ph.frames; } } - else if (playMode && popWand) { camPX = camX; camPY = camY; } // (the new-wand choice: the camera stays where it is - the world is frozen, the cursor picks a slot) + else if (playMode && (popWand || paused)) { camPX = camX; camPY = camY; } // (the new-wand choice and the pause: the camera stays where it is - the world is frozen) else if (playMode) { // smooth follow with a look-ahead toward the mouse float lookX = 0, lookY = 0; - if (mouseIn) { + if (mouseIn && !menuGame.over_game()) { // (dead: the camera stays on the body, no look-ahead) lookX = std::clamp((float)((g_in.mx - curW / 2) / scalePx) * 0.35f, -70.0f, 70.0f); lookY = std::clamp((float)((g_in.my - curH / 2) / scalePx) * 0.35f, -40.0f, 40.0f); } - const float tx = scriptCamFree ? (float)scriptCamX : ctrlPtr->outp[0] + lookX, ty = scriptCamFree ? (float)scriptCamY : ctrlPtr->outp[1] - 6.0f + lookY; // (GameSetCameraFree: the scripts' position) + float tx = scriptCamFree ? (float)scriptCamX : ctrlPtr->outp[0] + lookX, ty = scriptCamFree ? (float)scriptCamY : ctrlPtr->outp[1] - 6.0f + lookY; // (GameSetCameraFree: the scripts' position) + if (menuGame.over_game() && playerCorpseSlot >= 0) { // RAGDOLL_FX 9 (PLAYER_RAGDOLL_CAMERA): the camera follows the torso of the player's ragdoll + for (int k = 0; k < rigid.count(); ++k) { + if (rigid.bodies()[k].i[1] != playerCorpseSlot || (rigid.bodies()[k].i[2] & 2)) continue; + const ng::phys::Pose ps = physSys.pose(k); + if (ps.valid) { tx = (float)ps.x; ty = (float)ps.y; } + break; + } + } camPX = camX; camPY = camY; const float cdx = tx - camX, cdy = ty - camY; // the follow speeds up when the player is far (teleport bolts travel up to 16+ cells a tick) const float catchUp = 0.10f + 0.30f * std::clamp((std::max(std::fabs(cdx), std::fabs(cdy)) - 100.0f) / 150.0f, 0.0f, 1.0f); @@ -8185,7 +8523,7 @@ int main(int argc, char** argv) { static std::vector airF(kMaxEnts, 0), jumpF(kMaxEnts, -1000); for (int slot = 1; slot < entN && slot < kMaxEnts; ++slot) { const ng::EntityState& es = entReadPtr[slot]; - if ((es.st[0] != 1 && es.st[0] != 2) || (es.st[3] & ng::kEntDead) || (es.st[3] & 1024)) continue; + if ((es.st[0] != 1 && es.st[0] != 2) || (es.st[3] & ng::kEntDead) || ((es.st[3] & 1024) && (!slotToId[slot] || rt->components(slotToId[slot], "PhysicsAIComponent", "", true).empty()))) continue; // (host-moved - a worm, a dummy - unless PhysicsAI flies it: the wand ghost, steered by this AI) const int id = slotToId[slot]; if (!id) continue; { // frozen at the window's edge (entities.comp: not updated unless 3 cells inside): no ground test there - it kept "jump_fall" while it stood still @@ -8315,7 +8653,8 @@ int main(int argc, char** argv) { int prey = -1, nextDetect = 0, framesWithoutTarget = 0, aggression = 90; float hpSeen = -1.0f; int attacker = -1, jumpedAt = -1000, nextDash = 0, dashUntil = 0, meleeHitAt = -1, nextMelee = 0, rangedAt = -1, nextRanged = 0; float meleeDmg = 0; RangedSpec rspec; float aimAngle = 1e9f; int rangedStart = 0; bool dashHit = false; int threat = -1, prevState = 18, nearbyFood = 0, giveUpAt = 0, nextFoodScan = 0, eatSince = 0; bool noticedPlayer = false, attackedByPlayer = false, foundPrey = false; int stateSeen = -1, stateSwitchAt = 0; // mAiStateLastSwitchFrame (+0x31c) - int lastJump = -1000, prepJumpAt = -1, ownHerdHitUntil = 0, job = 18, lastItemUse = -1000; float prepVx = 0, prepVy = 0, prepT = 0; bool prepDash = false; int flightUntil = -1; + int lastJump = -1000, prepJumpAt = -1, ownHerdHitUntil = 0, job = 18, lastItemUse = -1000; bool randomLogic = false; /* (the RandomMove logic set on the PathFindingComponent, 00a232a0: it stays until a prey gives another goal) */ float prepVx = 0, prepVy = 0, prepT = 0; bool prepDash = false; int flightUntil = -1; + float lastNodeX = 0, lastNodeY = 0; bool hasLastNode = false; // (the last current node of the path: +0x15c keeps it after the path ends, until the next search) float dashTx = 0, dashTy = 0; // (00a281c0: the dash's target point, mNextJumpTarget with mNextJumpHasVelocity 0: the lob is solved at the launch, 00a28ba0) std::vector> avoid; std::vector avoidUntil; // the directions it got stuck going, its own (the search input's tree, 00860c20) }; @@ -8359,7 +8698,7 @@ int main(int argc, char** argv) { } for (int slot = 1; slot < entN && slot < kMaxEnts; ++slot) { ng::EntityState& es = entReadPtr[slot]; - if ((es.st[0] != 1 && es.st[0] != 2) || (es.st[3] & ng::kEntDead) || (es.st[3] & 1024)) continue; + if ((es.st[0] != 1 && es.st[0] != 2) || (es.st[3] & ng::kEntDead) || ((es.st[3] & 1024) && (!slotToId[slot] || rt->components(slotToId[slot], "PhysicsAIComponent", "", true).empty()))) continue; // (host-moved - a worm, a dummy - unless PhysicsAI flies it: the wand ghost, steered by this AI) const int id = slotToId[slot]; if (!id || !rt->alive(id)) continue; const auto ais = rt->components(id, "AnimalAIComponent", "", false); @@ -8651,6 +8990,7 @@ int main(int argc, char** argv) { // PathFindingLogic_Default (00864bf0): a node marked by a herd whose relation is below mAggression, or by the prey if (!flyer) { sp.goal = ng::ai::Goal::MoveNear; sp.gx = t.pos[0]; sp.gy = t.pos[1]; } else { sp.goal = ng::ai::Goal::Hunt; sp.gx = c.lastX; sp.gy = c.lastY; } + c.randomLogic = false; // (a prey: the hunt goal replaces the RandomMove logic) sp.own_herd = es.ex8[1] - 1; sp.aggression = c.aggression; sp.target_entity = c.prey == 0 ? rt->player() : slotToId[c.prey]; sp.depth_max = (int)an("pathfinding_max_depth_has_target", 120); mode = 1; @@ -8659,16 +8999,20 @@ int main(int argc, char** argv) { else { // mHasFoundPrey and no prey (00a25940): the path it has (to where the prey was) is followed to its end; 40 frames later the RandomMove logic // (00a232a0) is set and the PathFindingSystem searches with it at its cadence until a path comes; the state stays JobDefault (the real // game's ai_state, tools/noita_oracle/wand_ghost3.py: the wand ghost of SUMMON_WANDGHOST hops 20 .. 40 px every 40 .. 80 frames) + // (once set, the logic stays the component's: it searches again every frames_between_searches whenever the path is done - the real ghost's + // next_search_frame moves every 15 .. 30 frames; with the PathFindingComponent's search_depth_max_no_goal (20 nodes) most of those searches find + // nothing and the found paths are 1 .. 20 nodes: measured, the hops of 6 px and the 50 px of roaming in 20 s) if (c.pi < c.path.size()) { c.framesWithoutTarget = 0; mode = 1; } - else if (++c.framesWithoutTarget > 40) { - sp.goal = ng::ai::Goal::RandomMove; sp.random_distance = 10 + (int)(fxRng() % 191u); - sp.depth_max = std::getenv("NG_AIDEPTH") ? std::atoi(std::getenv("NG_AIDEPTH")) : (int)an("pathfinding_max_depth_no_target", 50); sp.iterations_max = (int)pn("iterations_max_no_goal", 1500); + else if (c.randomLogic || ++c.framesWithoutTarget > 40) { + c.randomLogic = true; + sp.goal = ng::ai::Goal::RandomMove; sp.random_distance = (int)an("max_distance_to_move_from_home", 0); + sp.depth_max = (int)pn("search_depth_max_no_goal", 20); sp.iterations_max = (int)pn("iterations_max_no_goal", 1500); mode = 1; } } } if (c.state == 1) { // RandomMove (00a232a0 / PathFindingLogic_RandomMove 00864ab0): a node 10 .. 200 pixels away - sp.goal = ng::ai::Goal::RandomMove; sp.random_distance = 10 + (int)(fxRng() % 191u); + sp.goal = ng::ai::Goal::RandomMove; sp.random_distance = (int)an("max_distance_to_move_from_home", 0); sp.depth_max = (int)an("pathfinding_max_depth_no_target", 50); sp.iterations_max = (int)pn("iterations_max_no_goal", 1500); mode = 1; @@ -8690,7 +9034,7 @@ int main(int argc, char** argv) { if (th >= 0 && th < entN && !(entReadPtr[th].st[3] & ng::kEntDead)) { sp.goal = ng::ai::Goal::Escape; sp.gx = entReadPtr[th].pos[0]; sp.gy = entReadPtr[th].pos[1]; sp.away_distance = (float)an("hide_from_prey_target_distance", 200); } - else { sp.goal = ng::ai::Goal::RandomMove; sp.random_distance = 10 + (int)(fxRng() % 191u); } + else { sp.goal = ng::ai::Goal::RandomMove; sp.random_distance = (int)an("max_distance_to_move_from_home", 0); } sp.depth_max = (int)an("pathfinding_max_depth_no_target", 50); sp.iterations_max = (int)pn("iterations_max_no_goal", 1500); mode = 1; } @@ -8748,8 +9092,9 @@ int main(int argc, char** argv) { c.path = ng::ai::search(pgrid, px, py, sp); // (00bca030 submits the entity transform; the search projects a walker down onto its ground, 00861e00 - docs/WEAK_ZOMBIE_AI_DIFFERENCES.md AI-06) if (std::getenv("NG_AISLOW")) { const double ms = std::chrono::duration(std::chrono::steady_clock::now() - st0).count(); if (ms > 3.0) std::printf("frame %d: slow search %.1f ms, %d nodes, ai %d (%s) goal %d jump %d fly %d path %zu%c", frame, ms, aiSearchWork - w0, id, rt->ent(id)->file.c_str(), (int)sp.goal, (int)sp.can_jump, (int)sp.can_fly, c.path.size(), 10); } // test c.pi = 0; // (path_frames_stuck carries over to the new path: 00bca030 resets it only when a node is reached) + c.hasLastNode = !c.path.empty(); if (c.hasLastNode) { c.lastNodeX = (float)c.path.back().x; c.lastNodeY = (float)c.path.back().y; } // (a one-node path is reached at once: the node stays the current one) c.nextSearch = frame + std::max(1, (int)pn("frames_between_searches", 30)) - (int)(fxRng() % 16u); - if (std::getenv("NG_AILOG")) std::printf("frame %d: ai %d (%s) at (%.0f, %.0f) state %d prey %d path %zu nodes%s%c", frame, id, rt->ent(id)->file.c_str(), px, py, c.state, c.prey, c.path.size(), std::any_of(c.path.begin(), c.path.end(), [](const ng::ai::PathNode& q) { return q.jump; }) ? " (jumps)" : "", 10); if (std::getenv("NG_PATHLOG")) { for (const auto& q : c.path) std::printf(" (%d,%d%s)", q.x, q.y, q.jump ? "J" : ""); std::printf(" feet %.1f rny %.1f%c", feetY, (float)pn("distance_to_reach_node_y", 6) + (float)pn("y_walking_compensation", 0), 10); } + if (std::getenv("NG_AILOG")) std::printf("frame %d: ai %d (%s) at (%.0f, %.0f) state %d prey %d goal %d depth %d dist %d path %zu nodes%s last (%.0f, %.0f) next %d%c", frame, id, rt->ent(id)->file.c_str(), px, py, c.state, c.prey, (int)sp.goal, sp.depth_max, sp.random_distance, c.path.size(), std::any_of(c.path.begin(), c.path.end(), [](const ng::ai::PathNode& q) { return q.jump; }) ? " (jumps)" : "", c.lastNodeX, c.lastNodeY, c.nextSearch, 10); if (std::getenv("NG_PATHLOG")) { for (const auto& q : c.path) std::printf(" (%d,%d%s)", q.x, q.y, q.jump ? "J" : ""); std::printf(" feet %.1f rny %.1f%c", feetY, (float)pn("distance_to_reach_node_y", 6) + (float)pn("y_walking_compensation", 0), 10); } } // -- following it (00bca030): a node is reached within distance_to_reach_node_x / _y; one not reached within frames_to_get_stuck marks its // direction blocked and the path is searched again @@ -9002,6 +9347,9 @@ int main(int argc, char** argv) { // 32: the run button (00a25d50: ControlsComponent +0x9c while there is a mGreatestPrey or a mGreatestThreat; the platforming 00a7ea90 doubles the walking // speed with it - a hunting weak zombie (run_velocity 18) walks at 36 px/s, measured in the game: v += 0.3 (36 - v) a frame) const bool runBtn = c.prey >= 0 || c.threat >= 0; + // 00bca030: a reached last node stays the PathFindingComponent's current node (+0x15c) until the next search sets it -1: a PhysicsAI body this AI steers + // (the wand ghost, flag 1024) keeps flying at it for those 15 .. 30 frames (the push behind its wobble and its 6 px hops, tools/noita_oracle/wand_ghost3.py) + if ((es.st[3] & 1024) && c.hasLastNode && c.pi >= c.path.size() && frame < c.nextSearch) { wx = c.lastNodeX; wy = c.lastNodeY; mode = 1; } const float st4[4] = {wx, wy, (float)mode, (flyer ? 1.0f : 0.0f) + (inJump ? 2.0f : 0.0f) + (frame < c.flightUntil ? 8.0f : 0.0f) + (flyWalk ? 16.0f : 0.0f) + (runBtn ? 32.0f : 0.0f)}; // (8: a dash in flight: no control even on the ground) if (std::memcmp(es.ex15, st4, 16) != 0) { std::memcpy(es.ex15, st4, 16); patchEntity(slot, offsetof(ng::EntityState, ex15), st4, 16); } aa["ai_state"] = std::to_string(c.state); @@ -9175,7 +9523,7 @@ int main(int argc, char** argv) { // of its PhysicsShapeComponent against the solid cells. The limbs (IKLimbWalkerSystem 00b440f0, IKLimbAttackerSystem 00b3ce80, IKLimbSystem 00b33010) // are its children; their sprites are part entities (entity_builder limb_parts). (Approximated: the goal is reached straight, without path nodes.) struct LimbSt { float ex = 0, ey = 0, tx = 0, ty = 0, pcx = 0, pcy = 0, side = 0, timer = 0, pex = 0, pey = 0; int state = 0, target = 0; bool init = false, att = false; }; - struct LimbBossSt { FlyPath fly; bool init = false; float apx = 0, apy = 0, vx = 0, vy = 0, nestX = 0, nestY = 0, holdX = 0, holdY = 0, goalX = 0, goalY = 0; int prevState = -1, nextGoal = 0; std::vector limbs; }; + struct LimbBossSt { FlyPath fly; bool init = false, hasLast = false; float apx = 0, apy = 0, vx = 0, vy = 0, nestX = 0, nestY = 0, holdX = 0, holdY = 0, goalX = 0, goalY = 0, lastX = 0, lastY = 0; int prevState = -1, nextGoal = 0; std::vector limbs; }; static std::map limbBosses; for (auto it = limbBosses.begin(); it != limbBosses.end();) { if (!rt->alive(it->first)) it = limbBosses.erase(it); else ++it; } const float dt = 1.0f / 60.0f; @@ -9240,7 +9588,10 @@ int main(int argc, char** argv) { // Real game (tools/noita_oracle/wand_ghost*.py): with no enemy it hovers about a point (the constant force overshoots: +-10 px, a 1 s period) and // hops 20 .. 40 px every 40 .. 80 frames aiNode = true; - if (es.ex15[2] > 0.5f && es.ex15[2] < 1.5f) { gx = es.ex15[0]; gy = es.ex15[1]; } else { gx = cx; gy = cy; } + // 00bce0a0: the target is the path's current node (mLastPosition = the body's position then, +0xa8); with none, mLastPosition: the RandomMove of a + // creature with max_distance_to_move_from_home 0 (the ghost) is a one-node path every 15 .. 30 frames, so it mostly holds the point it had + if (es.ex15[2] > 0.5f && es.ex15[2] < 1.5f) { gx = es.ex15[0]; gy = es.ex15[1]; b.lastX = cx; b.lastY = cy; b.hasLast = true; } + else if (b.hasLast) { gx = b.lastX; gy = b.lastY; } else { gx = cx; gy = cy; } } } else if ((state == 1 || state == 5) && havePlayer) { gx = plx; gy = ply; } @@ -9261,7 +9612,7 @@ int main(int argc, char** argv) { // -- PhysicsAI (00bce0a0, levitate): the force toward the goal const float fc = (float)num(pai, "force_coeff", 30), fb = (float)num(pai, "force_balancing_coeff", 1.5), fmax = (float)num(pai, "force_max", 100); const float grav = 12.0f; - float ddx = gx - cx, ddy = gy - cy; { const float l = std::hypot(ddx, ddy); if (l > 1.0f) { ddx /= l; ddy /= l; } else ddx = ddy = 0.0f; } + float ddx = gx - cx, ddy = gy - cy; { const float l = std::hypot(ddx, ddy); if (l > 1.0e-6f) { ddx /= l; ddy /= l; } else ddx = ddy = 0.0f; } // (00bce0a0: the unit vector at any distance: the full force overshoots, the body wobbles +-10 px about its point) float ax = ddx * fc - b.vx * fb, ay = ddy * fc - grav - b.vy * fb; { const float l = std::hypot(ax, ay); if (l > fmax) { ax *= fmax / l; ay *= fmax / l; } } ay += grav; @@ -9662,7 +10013,7 @@ int main(int argc, char** argv) { double fx = 0, fy = 0; if (num(pa, "levitate", 1) != 0 && mass > 0.0) { double dx = gx - px, dy = gy - py; const double l = std::hypot(dx, dy); - if (l > 1.0) { dx /= l; dy /= l; } else dx = dy = 0.0; + if (l > 1.0e-6) { dx /= l; dy /= l; } else dx = dy = 0.0; // (00bce0a0: the unit vector at any distance) const double fc = num(pa, "force_coeff", 30), fb = num(pa, "force_balancing_coeff", 1.5), fm = num(pa, "force_max", 100); double ax = dx * fc - vx * fb, ay = dy * fc - 12.0 - vy * fb; const double al = std::hypot(ax, ay); if (al > fm) { ax *= fm / al; ay *= fm / al; } @@ -10086,6 +10437,7 @@ int main(int argc, char** argv) { const int imin = (int)num("emission_interval_min_frames", 5), imax = (int)num("emission_interval_max_frames", imin); pa["_ng_next"] = std::to_string(frame + std::max(1, imin + (imax > imin ? (int)(fxRng() % (uint32_t)(imax - imin + 1)) : 0))); int cnt = (int)std::lround(rr("count_min", "count_max", 0)); + if (!real && settings.cosmetic_particles < 1.0f) { const double c = cnt * settings.cosmetic_particles; cnt = (int)c + (frand() < c - (int)c ? 1 : 0); } // the Options' cosmetic particle coefficient (006d5620): the count of a cosmetic emission // image_animation_file (00bc8bb0 / 00bc75e0): the image's pixels, with their offset from its centre, grouped by frame (the green channel); a time that grows by // image_animation_speed per emission (from image_animation_phase) emits the pixels of the frames it passes, in place of count_min..max; past the last frame it // starts again (image_animation_loop) or stops. Each pixel is emitted with the chance red / 255 x image_animation_emission_probability (the image: a green gradient of frames, the figure in red). (heart_fullhp_effect.xml) @@ -10413,6 +10765,13 @@ int main(int argc, char** argv) { break; } } + if (menuGame.over_game() && playerCorpseSlot >= 0 && ns < 16) // 00abb7d0: the torso of the player's ragdoll carries the FogOfWarRadiusComponent (the dead player's own clears where the body fell) + for (int k = 0; k < rigid.count(); ++k) { + if (rigid.bodies()[k].i[1] != playerCorpseSlot || (rigid.bodies()[k].i[2] & 2)) continue; + const ng::phys::Pose ps = physSys.pose(k); + if (ps.valid) { src[1 + ns * 3] = (float)ps.x; src[2 + ns * 3] = (float)ps.y; src[3 + ns * 3] = 256.0f; ++ns; } + break; + } src[0] = (float)ns; queueUp(g.tables, (VkDeviceSize)sb * 4, src.data(), (1 + (size_t)ns * 3) * 4); } @@ -10711,7 +11070,7 @@ int main(int argc, char** argv) { if (ai) { bg = ai->type; return iconFor(ai->sprite); } return nullptr; } - if (inv.is_flask(id)) { std::string fm; double fc = 0; flaskContents(id, fm, fc); fill = (float)(fc / 1000.0); return flaskIcon(fm, true); } + if (inv.is_flask(id)) { std::string fm; double fc = 0; flaskContents(id, fm, fc); return flaskIcon(fm, true, flaskFullness(id, fc)); } return wandSprite(id); }; auto itemLabel = [&](int id) -> std::string { // the number of uses of a spell @@ -10923,7 +11282,42 @@ int main(int argc, char** argv) { } } const double aimWx = viewX + aimXc, aimWy = viewY + aimYc; - const double ox = ctrlPtr->outp[0], oy = ctrlPtr->outp[1] - 5.0; + double ox = ctrlPtr->outp[0], oy = ctrlPtr->outp[1] - 5.0; + bool shootPosOk = false; + { // 009e4240: the shot starts at the held wand's "shoot_pos" hotspot in the wand's transform; 00b51670 places the wand's grip at the arm's "hand" hotspot + // turned toward the aim (fx_hand.comp draws it so), the hotspot offset turned with it and its y mirrored when aiming left (to-do #45) + double rx = 0, ry = 0; + const HotFrames& ah = hotFrames("data/enemies_gfx/player_arm.xml", "default", "hand"); + if (rt && rt->player() && gun->equipped() && ah.ok && !ah.px.empty() && ah.px[0].first >= 0 && hotspotWorld(rt->player(), "right_arm_root", rx, ry)) { + const double ang = std::hypot(aimWx - rx, aimWy - ry) > 0.5 ? std::atan2(aimWy - ry, aimWx - rx) : 0.0, c = std::cos(ang), s = std::sin(ang); + const bool mirror = c < 0.0; + const double hlx = ah.px[0].first + 0.5 - ah.offx, hly = (ah.px[0].second + 0.5 - ah.offy) * (mirror ? -1.0 : 1.0); + // 00b51670: the hand point + 0.5 x the holder's scale (the pixel's centre, mirrored), then the wand is pushed back along the aim when its tip + // (the shoot_pos point) is in a wall: a raycast hand -> tip, the push = -(the tip's depth past the hit - 2), approached at 40 /s (x dt x the gap), + // released at 20 px/s; the item's recoil offset comes off the same point (hand2.z of fx_hand.comp draws both) + const double hx = rx + c * hlx - s * hly + (mirror ? -0.5 : 0.5), hy = ry + s * hlx + c * hly + 0.5; + double spx = 0, spy = 0; + for (int hc : rt->components(gun->equipped(), "HotspotComponent", "shoot_pos", true)) { auto& ha = rt->comp(hc)->attrs; spx = std::atof(ha["offset.x"].c_str()); spy = std::atof(ha["offset.y"].c_str()); break; } + if (mirror) spy = -spy; + const double tx = hx + c * spx - s * spy, ty = hy + s * spx + c * spy; + { + double target = 0.0; + const int steps = std::max(1, (int)std::ceil(std::max(std::fabs(tx - hx), std::fabs(ty - hy)))); + for (int i = 0; i <= steps; ++i) { + const double qx = hx + (tx - hx) * i / steps, qy = hy + (ty - hy) * i / steps; + const int gx = (int)std::floor(qx) - viewX, gy = (int)std::floor(qy) - viewY; + if (gx < 0 || gy < 0 || gx >= kGridW || gy >= kGridH || !cellsReadPtr) break; + const uint32_t m = cellsReadPtr[gy * kGridW + gx] & 0xFFFFu; + if (m != 0 && (matTable[(size_t)m * 12 + 1] & (F_STATIC | F_SAND)) != 0) { target = -std::max(0.0, std::hypot(tx - qx, ty - qy) - 2.0); break; } + } + if (simOff) {} + else if (target - itemPushBack <= 0.0) itemPushBack += (target - itemPushBack) * (1.0 / 60.0) * 40.0; + else itemPushBack = std::min(target, itemPushBack + 20.0 / 60.0); + } + ox = tx + c * itemPushBack; oy = ty + s * itemPushBack; shootPosOk = true; + if (std::getenv("NG_SHOTLOG") && wantFire) std::printf("frame %d: shot origin (%.1f, %.1f): arm root (%.1f, %.1f) hand (%.1f, %.1f) shoot_pos (%.1f, %.1f) aim (%.0f, %.0f) body (%.1f, %.1f)%c", frame, ox, oy, rx, ry, hx, hy, spx, spy, aimWx, aimWy, ctrlPtr->outp[0], ctrlPtr->outp[1], 10); + } + } auto solidCell = [&](int x, int y) { const int wx = x - viewX, wy = y - viewY; if (wx < 0 || wy < 0 || wx >= kGridW || wy >= kGridH) return true; @@ -10936,22 +11330,54 @@ int main(int argc, char** argv) { const int fcomp = flaskContents(fid, fmat, fcount); const double dx0 = aimWx - ox, dy0 = aimWy - oy, dl = std::hypot(dx0, dy0); const double ux = dl > 1e-3 ? dx0 / dl : (ctrlPtr->outp[3] >= 0 ? 1.0 : -1.0), uy = dl > 1e-3 ? dy0 / dl : 0.0; - double mx = ox + ux * 6.0, my = oy + uy * 6.0; // the mouth of the flask, pulled back when it would be inside a wall - for (int back = 0; back < 6 && solidCell((int)std::floor(mx), (int)std::floor(my)); ++back) { mx -= ux; my -= uy; } + // The held item's transform, resolved from the same arm hotspot + // used by fx_hand.comp. A flask has no wand shoot_pos offset. + double mx = ctrlPtr->outp[0], my = ctrlPtr->outp[1] - 5.0; + double rx = mx, ry = my; + const HotFrames& arm = hotFrames("data/enemies_gfx/player_arm.xml", "default", "hand"); + if (arm.ok && !arm.px.empty() && arm.px[0].first >= 0 && hotspotWorld(rt->player(), "right_arm_root", rx, ry)) { + const double a = std::hypot(aimWx - rx, aimWy - ry) > .5 ? std::atan2(aimWy - ry, aimWx - rx) : 0; + const double c = std::cos(a), s = std::sin(a); + const double x = arm.px[0].first + .5 - arm.offx; + const double y = (arm.px[0].second + .5 - arm.offy) * (c < 0 ? -1 : 1); + mx = rx + c * x - s * y + (c < 0 ? -.5 : .5); + my = ry + s * x + c * y + .5; + } + double sprayX = mx, sprayY = my; + for (int hc : rt->components(fid, "HotspotComponent", "spray_pos", false)) { + const auto& ha = rt->comp(hc)->attrs; + auto get = [&](const char* k) { auto it = ha.find(k); return it == ha.end() ? 0.0 : std::atof(it->second.c_str()); }; + double x = get("offset.x"), y = get("offset.y"); + auto sn = ha.find("sprite_hotspot_name"); + if (sn != ha.end() && !sn->second.empty()) for (int sc : rt->components(fid, "SpriteComponent", "", true)) { + auto& sa = rt->comp(sc)->attrs; + const HotFrames& h = hotFrames(sa["image_file"], sa["rect_animation"], sn->second); + if (h.ok && !h.px.empty() && h.px[0].first >= 0) { x = h.px[0].first - h.offx; y = h.px[0].second - h.offy; break; } + } + auto scale = ha.find("transform_with_scale"); + if ((scale == ha.end() || scale->second != "0") && aimWx < rx) y = -y; + sprayX += x; sprayY += y; break; + } auto fparts = fcomp ? ng::mix_get(*rt, fcomp) : std::vector(); - if (wantFire && !fparts.empty() && fcount > 0 && fcomp) { // pouring: spray_velocity_coeff 75 (px/s) x between 0.75 and 1 depending on how far the cursor is, a few cells per frame - const int n = (int)std::min(fcount, 4.0); - const double sp = 75.0 / 60.0 * std::clamp(dl / 60.0, 0.75, 1.0); - for (int k = 0; k < n && pendingEdits.size() < 60; ++k) { // each cell is one of the materials, in proportion to what the flask holds - double pick = frand() * ng::mix_total(fparts); - size_t pi = 0; - while (pi + 1 < fparts.size() && pick >= fparts[pi].n) { pick -= fparts[pi].n; ++pi; } - const ng::Material* pm = db.find(fparts[pi].mat); - if (!pm || fparts[pi].n < 1.0) continue; - fparts[pi].n -= 1.0; - pendingEdits.push_back({(float)(mx + (frand() - 0.5) * 1.5), (float)(my + (frand() - 0.5) * 1.5), 0.9f, (float)(ux * sp + (frand() - 0.5) * 0.5), 3.0f, (float)pm->index, 1.0f, (float)(uy * sp + (frand() - 0.5) * 0.5)}); + if (wantFire && !fparts.empty() && fcomp) { + const auto pc = rt->components(fid, "PotionComponent", "", false); + const auto sk = rt->components(fid, "MaterialSuckerComponent", "", true); + if (!pc.empty() && !sk.empty()) { + const auto& pa = rt->comp(pc.front())->attrs; + auto num = [&](const char* k, double d) { auto it = pa.find(k); return it == pa.end() ? d : std::atof(it->second.c_str()); }; + ng::PotionSpray cfg; + cfg.speed = num("spray_velocity_coeff", 1); cfg.minimum = num("spray_velocity_normalized_min", .5); + cfg.bunch = num("throw_bunch", 0) != 0; cfg.count = (int)num("throw_how_many", 5); + cfg.skip_static = num("dont_spray_static_materials", 0) != 0; cfg.leak_gas = num("dont_spray_just_leak_gas_materials", 0) != 0; + const auto& sa = rt->comp(sk.front())->attrs; + auto cap = sa.find("barrel_size"); const double capacity = cap == sa.end() ? 1000 : std::atof(cap->second.c_str()); + const int attempts = ng::potion_spray(fparts, db, cfg, capacity, aimWx - ctrlPtr->outp[0], aimWy - (ctrlPtr->outp[1] - 5), entReadPtr[0].vel[0] * 60.0, + !potionFirePrev, frand, [&](const ng::Material& mat, double vx, double vy) { + emitPart(sprayX + .5, sprayY + .5, vx / 60, vy / 60, 720, 0, materialColor(mat.name), 7, mat.index); + if (std::getenv("NG_SPRAYLOG")) std::printf("spray %d item %d material %s position %.3f %.3f velocity %.3f %.3f\n", frame, fid, mat.name.c_str(), sprayX + .5, sprayY + .5, vx, vy); + }); + if (attempts) ng::mix_set(*rt, fcomp, fparts); } - ng::mix_set(*rt, fcomp, fparts); } { // Q (key_drink_potion): the flask in the hand is drunk, 5 units per frame; every material adds its ingestion statuses (IngestionSystem::IngestMaterial) const bool drinkKey = !playerPoly.active && ((!scripted && GetForegroundWindow() == hwnd && (GetAsyncKeyState('Q') & 0x8000)) || (scripted && std::getenv("NG_DRINK") && frame >= std::atoi(std::getenv("NG_DRINK")) && frame < std::atoi(std::getenv("NG_DRINK")) + 60)); @@ -10984,6 +11410,9 @@ int main(int argc, char** argv) { } } } + // A blocked inventory/UI frame must not turn a held button + // into a new throw_bunch press when the cursor leaves the UI. + potionFirePrev = g_in.lmb || (scripted && wantFire); rmbPrev = g_in.rmb || (scripted && std::getenv("NG_THROW") && frame == std::atoi(std::getenv("NG_THROW"))); if (playMode) { // MaterialSuckerComponent is enabled in the hand (at the mouth of the flask) and in the world (around a flask that lies there); a flask that is only carried does nothing suckTaken.clear(); @@ -11082,7 +11511,7 @@ int main(int argc, char** argv) { if (shot.mana <= 0.0) audio.play("magic_wand/out_of_mana", (float)ox, (float)oy); } auto sp = std::make_shared(std::move(shot)); - double sx = ox + dx * 8.0, sy = oy + dy * 8.0; // the muzzle, pulled back when it would start inside a wall + double sx = shootPosOk ? ox : ox + dx * 8.0, sy = shootPosOk ? oy : oy + dy * 8.0; // the muzzle (shoot_pos; 8 px ahead of the hand without one), pulled back when it would start inside a wall for (int back = 0; back < 8 && solidCell((int)std::floor(sx), (int)std::floor(sy)); ++back) { sx -= dx; sy -= dy; } if (audio.ok()) // the projectile entities' AudioComponent "create" (the cast's sound: an engine projectile is an entity, ours live on the GPU) for (const ng::ShotProjectile& pr : sp->root.projectiles) { @@ -11500,7 +11929,7 @@ int main(int argc, char** argv) { } { // HUD (CPU-drawn overlay): the GPU is idle, so the previous frame's control block and entity states are final hud::State hs; - hs.world = worldMode; hs.has_player = hasPlayer; hs.paused = paused; hs.brush_mode = brushMode; hs.free_cam = freeCam; + hs.world = worldMode; hs.has_player = hasPlayer; hs.paused = paused && !menuGame.active(); hs.brush_mode = brushMode; hs.free_cam = freeCam; // (a menu draws its own PAUSED) hs.debug = showDebug; hs.help = showHelp; hs.hp = ctrlPtr->outp[2]; hs.maxhp = playerMaxHp; hs.energy = ctrlPtr->outp2[0]; hs.air = ctrlPtr->outp2[2]; hs.burning = ctrlPtr->outp2[3] > 0.5f; if (rt) { @@ -11571,7 +12000,9 @@ int main(int argc, char** argv) { if (bi == sprBoom.end()) { ng::ExplosionFx fx; fx.sprite = effLib ? effLib->sprite(file) : nullptr; - if (fx.sprite) { fx.valid = true; fx.light = false; fx.sparks = false; fx.pivot_x = centred ? fx.sprite->w * 0.5f : 0.0f; fx.pivot_y = centred ? fx.sprite->h * 0.5f : 0.0f; } + // Uncentred particles retain the sprite XML's pivot; + // tinyspark_03's (3.5,3.5) anchors the lantern glow. + if (fx.sprite) { fx.valid = true; fx.light = false; fx.sparks = false; fx.pivot_x = centred ? fx.sprite->w * 0.5f : -1.0f; fx.pivot_y = centred ? fx.sprite->h * 0.5f : -1.0f; } bi = sprBoom.emplace(file + (centred ? "|c" : ""), fx.sprite ? gfx.boom_index(fx) : -1).first; } if (bi->second < 0) continue; @@ -11712,7 +12143,7 @@ int main(int argc, char** argv) { for (auto& t : L.texts) { t.x += bx; t.y += by; } hs.layers.push_back(std::move(L)); } - g_modal = popWand != 0 || g_textCapture; + g_modal = popWand != 0 || g_textCapture || menuGame.active(); if (popWand || invOpen) hs.hint.clear(); // (no prompt over the inventory or the choice) if (popWand && invp && rt->ent(popWand) && rt->ent(popWand)->alive) { // the new-wand choice (menu_newwand): the new wand on top, the four quick slots under it, compared with it ng::Inventory& inv = *invp; @@ -11790,7 +12221,7 @@ int main(int argc, char** argv) { if (inv.wands[choose] && dropFn) dropFn(inv.wands[choose], ctrlPtr->outp[0], ctrlPtr->outp[1] - 5.0, 0.0, 0.0); // the wand of that slot is dropped inv.wands[choose] = 0; pendingWandSlot[nw] = choose; - rt->pick(nw, rt->player()); + rt->pick(nw, rt->player()); ++runStats.items; if (std::getenv("NG_INVLOG")) std::printf("frame %d: new wand %d into slot %d%c", frame, nw, choose, 10); } } @@ -12079,7 +12510,33 @@ int main(int argc, char** argv) { } } hs.px = ctrlPtr->outp[0]; hs.py = ctrlPtr->outp[1]; hs.view_x = viewX; hs.view_y = viewY; - hs.mouse_x = mouseIn ? g_in.mx : -1; hs.mouse_y = g_in.my; + hs.mouse_x = mouseIn && !menuGame.active() ? g_in.mx : -1; hs.mouse_y = g_in.my; // (a menu draws the game's cursor instead of the crosshair) + hs.cursor_material.clear(); + bool mouseOverUi = false; + for (const auto& r : uiClickRects) if (g_in.mx >= r[0] && g_in.mx < r[0] + r[2] && g_in.my >= r[1] && g_in.my < r[1] + r[3]) { mouseOverUi = true; break; } + for (const auto& layer : hs.layers) for (const auto& panel : layer.panels) + if (g_in.mx >= panel.x && g_in.mx < panel.x + panel.w && g_in.my >= panel.y && g_in.my < panel.y + panel.h) mouseOverUi = true; + if (mouseIn && worldMode && !paused && !invOpen && !invFrame.drag && !mouseOverUi && cellsReadPtr) { + const int wx = viewX + (int)std::floor(cellOfX(g_in.mx)), wy = viewY + (int)std::floor(cellOfY(g_in.my)); + const int x = wx - cellsViewX, y = wy - cellsViewY; + if (x >= 0 && y >= 0 && x < kGridW && y < kGridH) { + int m = cellsReadPtr[y * kGridW + x] & 0xFFFFu; + // Native body cells share the material grid. Our rigid bodies + // have a separate readback, whose camera origin must also match. + const int bx = wx - bodyViewX, by = wy - bodyViewY; + if (bx >= 0 && by >= 0 && bx < kGridW && by < kGridH) { + const uint32_t slot = (bodyReadPtr[by * kGridW + bx] & 0x7FFFFFFFu); + if (slot > 0 && slot <= (uint32_t)entN && entReadPtr[slot - 1].st[0] == 4 && entReadPtr[slot - 1].ex5[2] > 0) + m = (int)std::lround(entReadPtr[slot - 1].ex5[2]) - 1; + } + m = db.canonical(m); + if (m > 0 && m < (int)db.materials().size()) { + const auto& mat = db.materials()[m]; + const auto it = mat.attrs.find("ui_name"); + hs.cursor_material = trKey(it == mat.attrs.end() ? "" : it->second, mat.name); + } + } + } hs.brush_select = g_in.select; hs.brush_radius = g_in.radius; hs.palette_names = paletteNames; hs.palette_colors = paletteColors; if (frame > 1) { // timestamps of the previous frame (the fence has been waited) uint64_t ts[2] = {0, 0}; @@ -12126,6 +12583,16 @@ int main(int argc, char** argv) { } if (std::getenv("NG_KILLPLAYER")) std::printf("frame %d: saving grace %d, extra life %d%c", frame, graces, lives, 10); } + if (respawns > 0 && menuEnabled && !spellTest && !menuGame.active()) { // the game over screen (006e51e0): the cause from the last damage type the GPU recorded (ex3.w), the run's stats written + const int dt = entReadPtr[0].ex3[3] & 0xFF; + std::string cause = dt & 8 ? "$damage_melee" : dt & 2 ? "$damage_explosion" : dt & 4 ? "$damage_fire" : dt & 1 ? "$damage_projectile" : "unknown"; + if (gameHost) { const std::string tc = gameHost->translate(cause); if (!tc.empty()) cause = tc; } + menuGame.game_over(cause); menuGame.set_in_game(false); + if (const int aw = entReadPtr[0].ex8[3] & 4095; aw > 1 && aw <= entN && rt) if (const ng::RtEntity* ae = rt->ent(slotToId[aw - 1])) { ++enemyStats[animalName(ae->file)].second; saveEnemyStats(); } // (the Progress screen's "Killed by") + onCreatureDeath(0, true); // the ragdoll (00abb7d0) where the body fell; the body itself is no longer drawn (ST_DEAD) + writeSession(true, cause); + ++totalDeaths; + } if (respawns > 0) { deathTimer = 150; if (std::getenv("NG_KILLPLAYER")) std::printf("frame %d: the player died (respawned)%c", frame, 10); if (rt && !spellTest) modLoader->call("OnPlayerDied", {ng::LVal::number(rt->player())}); } prevHp = hs.hp; } // (init.lua's OnPlayerDied destroys the inventory for the game over; the spell test room keeps it) if (deathTimer > 0 && !simOff) --deathTimer; hs.death_timer = deathTimer; @@ -12241,8 +12708,7 @@ int main(int argc, char** argv) { if (!fid) { ii.empty = true; hs.items.push_back(ii); continue; } std::string fm; double fc = 0; flaskContents(fid, fm, fc); - ii.icon = flaskIcon(fm, true); - ii.frac = (float)(fc / 1000.0); + ii.icon = flaskIcon(fm, true, flaskFullness(fid, fc)); { auto v = rt->components(fid, "MaterialInventoryComponent", "", true); const size_t np = v.empty() ? 0 : ng::mix_get(*rt, v.front()).size(); ii.name = fm.empty() ? std::string("empty flask") : (std::string("flask: ") + fm + (np > 1 ? " mix " : " ") + std::to_string((int)fc)); } std::replace(ii.name.begin(), ii.name.end(), '_', ' '); @@ -12263,7 +12729,9 @@ int main(int argc, char** argv) { hs.spell_icons.push_back(ai ? iconFor(ai->sprite) : nullptr); hs.spell_uses.push_back(ai ? gun->card_uses(c.entity) : -1); } - // wand in hand: drawn on the GPU (fx_hand.comp) pointing at the aim point: the cursor, or ahead of the player + } + if (activeItem >= 0 || (activeWand >= 0 && activeWand < (int)inv.wands.size() && inv.wands[activeWand] && gun->equipped())) { + // Held item, including a flask when no wand remains. const hud::Icon* wi = wandSprite(gun->equipped()); if (activeItem >= 0) { std::string fm; double fc = 0; flaskContents(inv.items[activeItem], fm, fc); wi = flaskIcon(fm, false); } if (wi && hasPlayer && !playerPoly.active && wi->w * wi->h <= 4096) { @@ -12276,19 +12744,29 @@ int main(int argc, char** argv) { if (it != handOffset.end()) { const double dX = viewX + camDX, dY = viewY + camDY; // the displayed camera double ax, ay; - if (mouseIn) { ax = dX + cellOfX(g_in.mx); ay = dY + cellOfY(g_in.my); } + static double heldAx = 0, heldAy = 0; static bool heldAim = false; // (paused: the wand keeps pointing where it did) + if (paused && heldAim) { ax = heldAx; ay = heldAy; } + else if (mouseIn) { ax = dX + cellOfX(g_in.mx); ay = dY + cellOfY(g_in.my); } else if (aimXc >= 0) { ax = viewX + aimXc; ay = viewY + aimYc; } // scripted aim else { ax = ctrlPtr->outp[0] + (ctrlPtr->outp[3] >= 0 ? 100.0 : -100.0); ay = ctrlPtr->outp[1] - 5.0; } + heldAx = ax; heldAy = ay; heldAim = true; ctrlPtr->hand0[0] = (float)ax; ctrlPtr->hand0[1] = (float)ay; ctrlPtr->hand0[2] = 1.0f; ctrlPtr->hand0[3] = 0.0f; - ctrlPtr->hand1[0] = (int32_t)(ng::GpuFx::kTexWords - 32768) + it->second; ctrlPtr->hand1[1] = wi->w; ctrlPtr->hand1[2] = wi->h; ctrlPtr->hand1[3] = 0; + ctrlPtr->hand1[0] = (int32_t)(ng::GpuFx::kTexWords - 32768) + it->second; ctrlPtr->hand1[1] = wi->w; ctrlPtr->hand1[2] = wi->h; ctrlPtr->hand1[3] = activeItem >= 0 ? 1 : 0; float pvx = 0.0f, pvy = wi->h * 0.5f; + if (activeItem >= 0) for (int sc : rt->components(inv.items[activeItem], "SpriteComponent", "", true)) { + const auto& sa = rt->comp(sc)->attrs; + auto x = sa.find("offset_x"), y = sa.find("offset_y"); + if (x != sa.end()) pvx = (float)std::atof(x->second.c_str()); + if (y != sa.end()) pvy = (float)std::atof(y->second.c_str()); + break; + } if (activeItem < 0) { auto abv = rt->components(gun->equipped(), "AbilityComponent", "", true); if (!abv.empty()) { auto pit = wandPivotBySprite.find(rt->comp(abv.front())->attrs["sprite_file"]); if (pit != wandPivotBySprite.end()) { pvx = pit->second.first; pvy = pit->second.second; } } } ctrlPtr->hand2[0] = pvx; ctrlPtr->hand2[1] = pvy; ctrlPtr->hand2[2] = 0.0f; { // the held item's recoil (00b51670): pushed back along the aim by item_recoil_offset_coeff x mItemRecoil px, turned muzzle-up by item_recoil_rotation_coeff x mItemRecoil // degrees; mItemRecoil -= item_recoil_recovery_speed x dt down to 0 (AbilityComponent defaults 1, 5, 15) double oc = 1.0, rc = 5.0, rs = 15.0; if (activeItem < 0) { auto abv = rt->components(gun->equipped(), "AbilityComponent", "", true); if (!abv.empty()) { auto& aa = rt->comp(abv.front())->attrs; auto num = [&](const char* k, double d) { auto f = aa.find(k); return f == aa.end() || f->second.empty() ? d : std::atof(f->second.c_str()); }; oc = num("item_recoil_offset_coeff", 1.0); rc = num("item_recoil_rotation_coeff", 5.0); rs = num("item_recoil_recovery_speed", 15.0); } } - ctrlPtr->hand2[2] = activeItem < 0 ? (float)(oc * itemRecoil) : 0.0f; + ctrlPtr->hand2[2] = activeItem < 0 ? (float)(oc * itemRecoil - itemPushBack) : 0.0f; // (+ the push back out of a wall, 00b51670) ctrlPtr->hand0[3] = activeItem < 0 ? (float)(rc * itemRecoil * 0.017453292) : 0.0f; if (!simOff) itemRecoil = std::max(0.0, itemRecoil - rs / 60.0); } @@ -12318,7 +12796,7 @@ int main(int argc, char** argv) { } } else ctrlPtr->hand0[2] = 0.0f; } - if (!(activeWand >= 0 && activeWand < (int)inv.wands.size() && inv.wands[activeWand] && gun->equipped())) ctrlPtr->hand0[2] = 0.0f; // (no wand: an empty hand - a held wand that went, e.g. GameDestroyInventoryItems, left its picture there) + if (menuGame.over_game() || (activeItem < 0 && !(activeWand >= 0 && activeWand < (int)inv.wands.size() && inv.wands[activeWand] && gun->equipped()))) ctrlPtr->hand0[2] = 0.0f; { // the items NPCs hold (npcHold), drawn by fx_hand.comp like the player's: a list of 10 entries of 12 words at the end of the hand region (word 32640: the count; // per entry: the slot + 1, the image (absolute texel offset, w, h), the grip (the sprite's pivot), the hand relative to the body, the aim point) uint32_t* H = fxTexPtr + (ng::GpuFx::kTexWords - 32768); @@ -12496,7 +12974,96 @@ int main(int argc, char** argv) { CPUMARK(2); PERFMARK("HUD drawing"); if (playerPoly.active) { hs.wands.clear(); hs.items.clear(); hs.spell_icons.clear(); hs.spell_uses.clear(); hs.active_wand = -1; hs.active_slot = -1; } // the polymorphed body has no inventory - hud::draw(canvas, hudAssets, hs); + if (!settings.damage_numbers) hs.damage_texts.clear(); + if (menuGame.over_game()) { canvas.clear(); canvas.font = hudAssets.font.ok() ? &hudAssets.font : nullptr; } else hud::draw(canvas, hudAssets, hs); // (the game over: the world without the HUD under the screen) + if (menuEnabled) { // ---- the menus over the HUD (src/app/menu.h) ---- + if (hasPlayer && !spellTest && !(menuGame.active() && !menuGame.in_game())) { // the run's stats (the game over box; they stop at the death): gold, time, depth, places, max hp; kills and items are counted where they happen + runStats.gold = rt ? rt->money() : 0; runStats.playtime = frame / 60.0; + runStats.depth = std::max(runStats.depth, (int)std::floor(entReadPtr[0].pos[1] / 10.0)); + runStats.maxhp = std::max(runStats.maxhp, (int)std::lround(playerMaxHp * 25.0f)); + if (areaStore && frame % 60 == 0) { const ng::BiomeDef* bd = areaStore->biome_map().biome_at((int)std::floor(entReadPtr[0].pos[0] / 512.0) + 35, (int)std::floor(entReadPtr[0].pos[1] / 512.0) + 14); if (bd && !bd->name.empty()) placesVisited.insert(bd->name); } + runStats.places = (int)placesVisited.size(); + } + if (g_in.escape && !menuGame.active() && !g_textCapture && popWand == 0) { menuGame.open(menu::Menu::Screen::Pause); g_in.escape = false; } + { // the pause cue (ui/game_paused) on the way in and out, and the sounds hold while a menu is up (FmodAudio::set_paused: the loops and the music) + static bool menuWas = false; + if ((menuGame.active() && !menuGame.over_game()) != menuWas) { + menuWas = menuGame.active() && !menuGame.over_game(); + if (hasPlayer) audio.play("ui/game_paused", ctrlPtr->outp[0], ctrlPtr->outp[1]); + audio.set_paused(menuWas); + } + } + menu::Context mc; + mc.w = curW; mc.h = curH; mc.mx = g_in.mx; mc.my = g_in.my; mc.lmb = g_in.lmb; + mc.click = g_in.lmb_down_n != menuClickSeen; menuClickSeen = g_in.lmb_down_n; + mc.wheel = menuGame.active() ? g_in.wheel : 0; if (menuGame.active()) g_in.wheel = 0; + mc.escape = g_in.escape && menuGame.active(); if (mc.escape) g_in.escape = false; + mc.key_pressed = g_in.last_key; g_in.last_key = 0; + mc.has_run = (hasPlayer && worldMode) || continueRelaunch; mc.mods_used = !enabledMods.empty(); mc.dead = menuGame.active() && !menuGame.in_game(); + mc.build = std::string( +#ifdef NG_BUILD + NG_BUILD +#else + "dev" +#endif + ); + mc.run_mode = runModeName; mc.seed = worldSeed; + mc.release_notes = &releaseNotes; mc.modes = &gameModes; mc.mods = &enabledMods; mc.stats = runStats; mc.best = bestStats; mc.total_deaths = totalDeaths; mc.total_wins = totalWins; + mc.settings = &settings; + mc.all_mods = &allMods; mc.replay_available = clipRec && clipRec->frames() > 0; mc.suspended = g_gifOpen; + { // the Progress screen's data (built when it opens: the flags may have changed) + static menu::Menu::Screen lastScreen = menu::Menu::Screen::None; + if (menuGame.screen == menu::Menu::Screen::Progress && lastScreen != menu::Menu::Screen::Progress) buildProgress(); + lastScreen = menuGame.screen; + mc.perks = &progPerks; mc.actions = &progActions; mc.enemies = &progEnemies; + const auto& fl = ng::LuaHost::persistent_flags(); + mc.ending0 = fl.count("progress_ending0") != 0; mc.ending1 = fl.count("progress_ending1") != 0; + for (const std::string& f : fl) if (f.compare(0, 7, "secret_") == 0 || f.compare(0, 9, "progress_") == 0 || f.compare(0, 6, "final_") == 0) ++mc.secrets_found; + } + mc.tr = [&](const std::string& k) -> std::string { const std::string& v = ng::LuaHost::translate_in(wak, std::filesystem::path(argv[1]).parent_path().string(), k); return v.empty() ? k : v; }; // (the title screen has no run, so no game host: the table is static) + const menu::Result mr = menuGame.frame(canvas, menuAssets, mc); + if (settings.cursor && !menuGame.active() && menuAssets.cursor.ok()) canvas.icon(menuAssets.cursor, g_in.mx, g_in.my, std::max(1, (int)std::lround(curH / 360.0) / 2)); + switch (mr.action) { + case menu::Action::Continue: + if (continueRelaunch) { // save/run.txt: " " of the last run the menu started; its world in save/run.world + std::string seedStr, modeStr; { std::istringstream is(continueArgs); is >> seedStr >> modeStr; } + if (relaunchRun(seedStr, modeStr, 0)) break; + } + menuGame.set_in_game(true); break; + case menu::Action::SaveReplay: // 006e51e0: the clip editor over the game over (the game holds completely while it is up) + if (clipRec && clipRec->frames() > 0 && !gifEd.is_open()) { gifEd.open(clipRec->snapshot()); gifEd.set_meta(gifMetaNow()); g_in.lmb = g_in.rmb = false; g_in.attack = false; } + break; + case menu::Action::ApplyMods: saveMods(); break; + case menu::Action::OpenModFolder: { std::error_code ec; const std::string abs = std::filesystem::absolute(gameDir / "mods", ec).string(); ShellExecuteA(nullptr, "open", abs.c_str(), nullptr, nullptr, SW_SHOWNORMAL); break; } + case menu::Action::RestartMods: { // the run again with the Mods screen's list (the mod loader reads save/mod_config.xml at the start) + std::string seedStr = std::to_string(worldSeed), modeStr = runModeName; + if (continueRelaunch) { std::istringstream is(continueArgs); is >> seedStr >> modeStr; } + if (!relaunchRun(seedStr, modeStr, 0)) menuGame.set_in_game(true); + break; + } + case menu::Action::ProgressSeen: progressSeenNow(); break; + case menu::Action::Quit: g_in.quit = true; break; + case menu::Action::SaveQuit: g_in.quit = true; break; + case menu::Action::ResetProgress: { // the Options' "Reset all progress": our own save folder's stats, flags and last daily + std::error_code ec; const std::filesystem::path saveDir = std::filesystem::path(exeDirStr) / "save"; + std::filesystem::remove_all(saveDir / "stats", ec); std::filesystem::remove(saveDir / "persistent_flags.txt", ec); std::filesystem::create_directories(saveDir / "stats" / "sessions", ec); + bestStats = menu::RunStats(); totalDeaths = totalWins = 0; menuGame.last_daily_played = 0; + break; + } + case menu::Action::ApplySettings: applySettings(); break; + case menu::Action::StartGame: { // a new run: the executable again with the seed (and the game mode as the mods / the daily flags), this one quits + { // the new run replaces the saved one (save/run.world + .state), run.txt names it for Continue + std::error_code ec; const std::filesystem::path saveDir = std::filesystem::path(exeDirStr) / "save"; + std::filesystem::remove(saveDir / "run.world", ec); std::filesystem::remove(saveDir / "run.world.state", ec); + std::ofstream rf(saveDir / "run.txt"); rf << mr.seed << " " << mr.mode_id; + } + if (mr.daily) { std::ofstream ld(std::filesystem::path(exeDirStr) / "save" / "stats" / "last_daily_run_played.txt"); ld << mr.seed; } + relaunchRun(std::to_string(mr.seed), mr.mode_id, mr.daily ? 1 : mr.daily_practice ? 2 : 0); + break; + } + default: break; + } + } if (showDebug && showColliders) { PERFMARK("collider overlay drawing"); const std::array debugViewport{viewX + (offX - 4) / scalePx, viewY + (offY - 4) / scalePx, @@ -14346,7 +14913,7 @@ int main(int argc, char** argv) { const uint32_t m = cellsReadPtr[gy * kGridW + gx] & 0xFFFFu; if (m && (matTable[(size_t)m * 12 + 1] & F_STATIC)) { const ng::RtEntity* be = rt && b.i[1] >= 0 && b.i[1] < kMaxEnts && slotToId[b.i[1]] ? rt->ent(slotToId[b.i[1]]) : nullptr; std::printf("frame %d: body %d slot %d (%s) centre (%.1f, %.1f) inside %s, v (%.2f, %.2f)%c", frame, k, b.i[1], be ? be->file.c_str() : "-", b.p[0], b.p[1], db.materials()[m].name.c_str(), b.v[0], b.v[1], 10); } } - if (doTick && !popWand) ++frame; // (the new-wand choice freezes the game: the frame counter too, or the timers of the GPU and the scripts run on) + if (doTick && !popWand && (!paused || scripted)) ++frame; // (paused: the frame counter holds too - sprites.comp animates from it; a scripted run counts on to its --frames shot with a menu up) // (the new-wand choice freezes the game: the frame counter too, or the timers of the GPU and the scripts run on) ++fpsFrames; ++totalFrames; auto now = std::chrono::steady_clock::now(); double el = std::chrono::duration(now - fpsT0).count(); @@ -14359,6 +14926,7 @@ int main(int argc, char** argv) { } } VK(vkDeviceWaitIdle(g.dev)); + if (menuEnabled && hasPlayer && !spellTest) writeSession(false, ""); if (worldMode && !worldFile.empty()) { // flush the window into the store and save it VK(vkResetCommandBuffer(g.cmd, 0)); VkCommandBufferBeginInfo fbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; diff --git a/src/app/menu.h b/src/app/menu.h new file mode 100644 index 0000000..7708161 --- /dev/null +++ b/src/app/menu.h @@ -0,0 +1,765 @@ +// The game's menus, drawn into the HUD canvas: the title screen (006e3410 with DAT_0120761b off), the pause menu (the same with the help image), the new game +// panel with the game modes (006ceb00 / 006378a0: "normal" and "dailyrun" built in, then every mod with is_game_mode in its mod.xml), the daily run's seed from +// the game's backend (0084c6f0: GET https://takapuoli.noitagame.com/callback/ -> "version;daily seed;daily practice seed;1;"), the options (006d5620: the tabs +// General / Graphics / Audio / Input / Streaming / Mod settings), the release notes (0084d020 reads _release_notes.txt beside the game: here release_notes.txt +// beside our executable, written from the git log at configure time - every entry is a commit, its hash the version), the credits (data/credits.txt) and the +// game over screen with the run's stats (006e51e0: the cause of death, then the stats box with the bests, New Game / Save death replay / Progress / Quit). +// Layout in GUI units: `gs` window pixels each (the game's GUI scale: the window height / 360, so a 1440p window draws at 4 like the game). +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/hud.h" +#include "core/wak.h" + +#define WIN32_LEAN_AND_MEAN +#include +#include +#pragma comment(lib, "wininet.lib") + +namespace menu { + +// ---------------------------------------------------------------- assets ---------------------------------------------------------------- +struct Assets { + hud::Icon logo, gameOver, gameCompleted, help, p9, p9gray, tab, tabHover, tabSel, bannerSel, grass, bannerNormalBg, bannerNormalFg, bannerDailyBg, bannerDailyFg, + mountains1, mountains2, layer1, layer2, clouds1, stars[7], cursor, gridBox, gridBoxUnknown, gridNew, iconUnknown; + std::vector sky; int skyW = 0, skyH = 0; // weather_gfx/parallax_colors_main_menu.bmp: a column is the sky's gradient from the top (row 0) down + std::string credits; + bool loaded = false; + static bool load_bmp(const std::vector& raw, std::vector& px, int& w, int& h) { // 24-bit uncompressed, bottom-up + if (raw.size() < 54 || raw[0] != 'B' || raw[1] != 'M') return false; + const uint32_t off = *reinterpret_cast(&raw[10]); + const int32_t bw = *reinterpret_cast(&raw[18]), bh = *reinterpret_cast(&raw[22]); + const uint16_t bpp = *reinterpret_cast(&raw[28]); + if (bpp != 24 || bw <= 0 || bh == 0) return false; + const int ah = std::abs(bh), stride = (bw * 3 + 3) & ~3; + if (off + (size_t)stride * ah > raw.size()) return false; + w = bw; h = ah; px.assign((size_t)w * h, 0); + for (int y = 0; y < h; ++y) { + const uint8_t* row = &raw[off + (size_t)(bh > 0 ? h - 1 - y : y) * stride]; + for (int x = 0; x < w; ++x) px[(size_t)y * w + x] = 0xFF000000u | (uint32_t)row[x * 3 + 2] << 16 | (uint32_t)row[x * 3 + 1] << 8 | row[x * 3]; + } + return true; + } + void load(const ng::Wak& wak) { + auto L = [&](const char* p) { return hud::load_icon(wak, p); }; + logo = L("data/ui_gfx/pause_menu/noita_logo.png"); gameOver = L("data/ui_gfx/game_over_menu/game_over.png"); gameCompleted = L("data/ui_gfx/game_over_menu/game_completed.png"); + help = L("data/ui_gfx/pause_menu/help_keyboardmouse.png"); p9 = L("data/ui_gfx/decorations/9piece0.png"); p9gray = L("data/ui_gfx/decorations/9piece0_gray.png"); + tab = L("data/ui_gfx/decorations/tab.png"); tabHover = L("data/ui_gfx/decorations/tab_hovered.png"); tabSel = L("data/ui_gfx/decorations/tab_selected.png"); + bannerSel = L("data/ui_gfx/game_mode/banner_selected_overlay.png"); grass = L("data/ui_gfx/game_mode/background_grass.png"); + bannerNormalBg = L("data/ui_gfx/game_mode/banner_normal_background.png"); bannerNormalFg = L("data/ui_gfx/game_mode/banner_normal_overlay.png"); + bannerDailyBg = L("data/ui_gfx/game_mode/banner_dailyrun_background.png"); bannerDailyFg = L("data/ui_gfx/game_mode/banner_dailyrun_overlay.png"); + mountains1 = L("data/weather_gfx/parallax_mountains_01.png"); mountains2 = L("data/weather_gfx/parallax_mountains_02.png"); + layer1 = L("data/weather_gfx/parallax_mountains_layer_01.png"); layer2 = L("data/weather_gfx/parallax_mountains_layer_02.png"); clouds1 = L("data/weather_gfx/parallax_clounds_01.png"); + for (int i = 0; i < 7; ++i) stars[i] = L(("data/weather_gfx/star_00" + std::to_string(i + 1) + ".png").c_str()); + cursor = L("data/ui_gfx/mouse_cursor.png"); + gridBox = L("data/ui_gfx/progress_menu/grid_box.png"); gridBoxUnknown = L("data/ui_gfx/progress_menu/grid_box_unknown.png"); // (006dfe40: the Progress screen's cells) + gridNew = L("data/ui_gfx/progress_menu/grid_highlight_new.png"); iconUnknown = L("data/ui_gfx/progress_menu/icon_unknown.png"); + std::vector raw; + if (wak.read("data/weather_gfx/parallax_colors_main_menu.bmp", raw)) load_bmp(raw, sky, skyW, skyH); + if (wak.read("data/credits.txt", raw)) credits.assign(raw.begin(), raw.end()); + loaded = true; + } +}; + +// ---------------------------------------------------------------- settings ---------------------------------------------------------------- +// The options of the Options screen, kept in /settings.txt (key=value). The engine reads them where they apply (main.cpp). +struct KeyBind { const char* id; const char* name; int key; }; +struct Settings { + int window_mode = 0; // 0 windowed, 1 borderless fullscreen + bool vsync = true, cursor = false, replay_recorder = false, damage_numbers = true, pause_unfocused = true, click_switches_item = false, hover_info_at_mouse = false; + float music = 1.0f, sfx = 1.0f, screenshake = 1.0f; + // 006d5620's sliders: brightness -0.09 .. 0.09, contrast -0.12 .. 0.2, gamma -0.32 .. 0.695 (all 0 by default: post_final.frag adds the brightness, scales about + // 0.5 by 1 + contrast, raises to 1 + gamma), the cosmetic particle coefficient 0.05 .. 1 (the count of every cosmetic emission) + float brightness = 0.0f, contrast = 0.0f, gamma = 0.0f, cosmetic_particles = 1.0f; + static constexpr float kBrightMin = -0.09f, kBrightMax = 0.09f, kContrastMin = -0.12f, kContrastMax = 0.2f, kGammaMin = -0.32f, kGammaMax = 0.695f, kCosmeticMin = 0.05f; + bool online = true, streamer_mode = false; // (streamer mode: the game's Twitch integration switch - 006ceb00 opens the Streaming tab when it goes on) + std::vector keys = {{"left", "Move left", 'A'}, {"right", "Move right", 'D'}, {"up", "Levitate / jump", 'W'}, {"down", "Move down", 'S'}, {"kick", "Kick", 'F'}, + {"interact", "Interact / pick up", 'E'}, {"inventory", "Inventory", VK_TAB}, {"drop", "Drop item", 'Q'}, {"pause", "Pause", 'P'}}; + int key(const char* id) const { for (const KeyBind& k : keys) if (!std::strcmp(k.id, id)) return k.key; return 0; } + std::string path; + void load(const std::string& p) { + path = p; + FILE* f = std::fopen(p.c_str(), "rb"); + if (!f) return; + char line[512]; + while (std::fgets(line, sizeof line, f)) { + std::string s(line); while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) s.pop_back(); + const size_t eq = s.find('='); + if (eq == std::string::npos) continue; + const std::string k = s.substr(0, eq), v = s.substr(eq + 1); + auto b = [&](const char* n, bool& dst) { if (k == n) dst = v == "1"; }; + auto fl = [&](const char* n, float& dst) { if (k == n) dst = (float)std::atof(v.c_str()); }; + if (k == "window_mode") window_mode = std::atoi(v.c_str()); + b("vsync", vsync); b("cursor", cursor); b("replay_recorder", replay_recorder); b("damage_numbers", damage_numbers); b("pause_unfocused", pause_unfocused); + b("click_switches_item", click_switches_item); b("hover_info_at_mouse", hover_info_at_mouse); b("online", online); b("streamer_mode", streamer_mode); + fl("music", music); fl("sfx", sfx); fl("screenshake", screenshake); fl("brightness", brightness); fl("contrast", contrast); fl("gamma", gamma); fl("cosmetic_particles", cosmetic_particles); + if (k.compare(0, 4, "key_") == 0) for (KeyBind& kb : keys) if (k.substr(4) == kb.id) kb.key = std::atoi(v.c_str()); + } + std::fclose(f); + brightness = std::clamp(brightness, kBrightMin, kBrightMax); contrast = std::clamp(contrast, kContrastMin, kContrastMax); gamma = std::clamp(gamma, kGammaMin, kGammaMax); + cosmetic_particles = std::clamp(cosmetic_particles, kCosmeticMin, 1.0f); + } + void save() const { + if (path.empty()) return; + FILE* f = std::fopen(path.c_str(), "wb"); + if (!f) return; + std::fprintf(f, "window_mode=%d\nvsync=%d\ncursor=%d\nreplay_recorder=%d\ndamage_numbers=%d\npause_unfocused=%d\nclick_switches_item=%d\nhover_info_at_mouse=%d\nonline=%d\nstreamer_mode=%d\n", + window_mode, vsync, cursor, replay_recorder, damage_numbers, pause_unfocused, click_switches_item, hover_info_at_mouse, online, streamer_mode); + std::fprintf(f, "music=%.3f\nsfx=%.3f\nscreenshake=%.3f\nbrightness=%.3f\ncontrast=%.3f\ngamma=%.3f\ncosmetic_particles=%.3f\n", music, sfx, screenshake, brightness, contrast, gamma, cosmetic_particles); + for (const KeyBind& k : keys) std::fprintf(f, "key_%s=%d\n", k.id, k.key); + std::fclose(f); + } +}; + +// ---------------------------------------------------------------- the run, its stats, the game modes ---------------------------------------------------------------- +struct GameMode { std::string id, name, desc, banner_bg, banner_fg; bool mod = false; }; +struct RunStats { int gold = 0; double playtime = 0; int depth = 0, places = 0, kills = 0, maxhp = 0, items = 0; }; // (depth in metres: the deepest point / 10) +struct ProgressItem { std::string id, icon, name, desc; bool known = false, is_new = false; int kills = 0, killed_by = 0; }; // a perk, a spell or an enemy of the Progress screen (006dfe40) +struct ModInfo { std::string id, name, desc; bool enabled = false, game_mode = false; }; // a folder of mods/ (the Mods screen: 00b84f0's list, enabled in order) +struct Context { // what main.cpp knows, given to the menu every frame + int w = 0, h = 0, mx = 0, my = 0, wheel = 0; + bool click = false, lmb = false, escape = false, has_run = false, mods_used = false, dead = false; + int key_pressed = 0; // a key that went down this frame (the Input tab's rebinding) + std::string build, run_mode, location, death_cause; + uint32_t seed = 0; + const std::string* release_notes = nullptr; // the text beside the executable + const std::vector* modes = nullptr; + const std::vector* mods = nullptr; // the enabled mods + RunStats stats, best; int total_deaths = 0, total_wins = 0; + Settings* settings = nullptr; + const std::vector* perks = nullptr; const std::vector* actions = nullptr; const std::vector* enemies = nullptr; // the Progress screen + int secrets_found = 0; bool ending0 = false, ending1 = false; // (the progress_* flags: the secret_* / progress_* ones found, the two endings) + std::vector* all_mods = nullptr; // every mod in mods/: the Mods screen toggles and reorders them, Action::ApplyMods writes the list + bool replay_available = false; // the replay recorder holds frames: the game over's "Save death replay" opens the clip editor + bool suspended = false; // the clip editor is up over the menu: nothing drawn, nothing read + std::function tr; // "$menu_continue" -> the game's text (common.csv), the key itself when unknown +}; +enum class Action { None, Continue, StartGame, Quit, SaveQuit, ApplySettings, ResetProgress, SaveReplay, ApplyMods, OpenModFolder, RestartMods, ProgressSeen }; +struct Result { Action action = Action::None; std::string mode_id; uint32_t seed = 0; bool daily = false, daily_practice = false; }; + +// ---------------------------------------------------------------- the daily seed ---------------------------------------------------------------- +// 0084c6f0: HTTPS GET takapuoli.noitagame.com/callback/ (WinINet); the body ";;;1;" (the game reads it as lines) +struct Daily { + std::atomic state{0}; // 0 not asked, 1 fetching, 2 ok, 3 failed + uint32_t seed = 0, practice = 0; + std::string version; + std::thread th; + ~Daily() { if (th.joinable()) th.detach(); } + void fetch() { + if (state.load() != 0) return; + state = 1; + th = std::thread([this] { + std::string body; + HINTERNET net = InternetOpenA("Noita", INTERNET_OPEN_TYPE_PRECONFIG, nullptr, nullptr, 0); + if (net) { + HINTERNET con = InternetConnectA(net, "takapuoli.noitagame.com", 443, "", "", INTERNET_SERVICE_HTTP, 0, 0); + if (con) { + HINTERNET req = HttpOpenRequestA(con, "GET", "/callback/", "HTTP/1.0", nullptr, nullptr, INTERNET_FLAG_SECURE | INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE, 0); + if (req && HttpSendRequestA(req, nullptr, 0, nullptr, 0)) { + char buf[1024]; DWORD n = 0; + while (InternetReadFile(req, buf, sizeof buf, &n) && n > 0 && body.size() < 4096) body.append(buf, n); + } + if (req) InternetCloseHandle(req); + InternetCloseHandle(con); + } + InternetCloseHandle(net); + } + std::vector parts; std::string cur; + for (char ch : body) { if (ch == ';' || ch == '\n') { parts.push_back(cur); cur.clear(); } else if (ch != '\r') cur += ch; } + if (!cur.empty()) parts.push_back(cur); + auto num = [](const std::string& s, uint32_t& out) { if (s.empty() || s.size() > 10 || s.find_first_not_of("0123456789") != std::string::npos) return false; out = (uint32_t)std::strtoul(s.c_str(), nullptr, 10); return true; }; + if (parts.size() >= 3 && num(parts[1], seed) && num(parts[2], practice)) { version = parts[0]; state = 2; } + else state = 3; + }); + } +}; + +// ---------------------------------------------------------------- the menu ---------------------------------------------------------------- +class Menu { +public: + enum class Screen { None, Title, NewGame, Pause, Options, ReleaseNotes, Credits, Mods, Progress, GameOver, GameOverStats, Confirm }; + Screen screen = Screen::None; + Daily daily; + bool active() const { return screen != Screen::None; } + void open(Screen s) { screen = s; hover_ = -1; scroll_ = 0; if (s == Screen::NewGame) { selMode_ = -1; } if (s == Screen::Credits) creditsT_ = 0.0; } + void game_over(const std::string& cause) { cause_ = cause; gameOverT_ = 0.0; screen = Screen::GameOver; } + void confirm(const std::string& text, Action yes) { confirmText_ = text; confirmAction_ = yes; confirmBack_ = screen; screen = Screen::Confirm; } + bool in_game() const { return inGame_; } + bool over_game() const { return screen == Screen::GameOver || screen == Screen::GameOverStats; } // (the game over plays over the running world: no pause, no background) + void set_in_game(bool b) { inGame_ = b; } + uint32_t last_daily_played = 0; // (stats/_last_daily_run_played.txt: the daily can be played once a day) + + Result frame(hud::Canvas& cv, const Assets& as, const Context& c) { + Result r; + if (!active() || c.suspended) return r; + gs_ = std::max(1, (int)std::lround(c.h / 360.0)); + cv_ = &cv; as_ = &as; c_ = &c; tick_ += 1.0 / 60.0; + clicked_ = c.click; hot_ = -1; itemN_ = 0; + if (screen == Screen::Title || screen == Screen::NewGame || (!inGame_ && !c.dead && (screen == Screen::Options || screen == Screen::ReleaseNotes || screen == Screen::Credits || screen == Screen::Mods || screen == Screen::Progress || screen == Screen::Confirm))) + background(); + else if (!over_game()) { cv.rect(0, 0, cv.w, cv.h, hud::rgba(0, 0, 0, 150)); cv.mark_all(); } + switch (screen) { + case Screen::Title: title(r); break; + case Screen::Pause: pause(r); break; + case Screen::NewGame: newGame(r); break; + case Screen::Options: options(r); break; + case Screen::ReleaseNotes: releaseNotes(); break; + case Screen::Credits: credits(); break; + case Screen::Mods: mods(r); break; + case Screen::Progress: progress(r); break; + case Screen::GameOver: gameOver(r); break; + case Screen::GameOverStats: gameOverStats(r); break; + case Screen::Confirm: confirmScreen(r); break; + default: break; + } + if (c.escape) { // Esc: back to the menu behind, or (from the pause menu) back to the game + if (screen == Screen::Pause) { screen = Screen::None; r.action = Action::Continue; } + else if (screen == Screen::Title || screen == Screen::GameOver || screen == Screen::GameOverStats) {} + else if (screen == Screen::NewGame && c.dead) screen = Screen::GameOverStats; + else if (screen == Screen::Confirm) screen = confirmBack_; + else { if (screen == Screen::Progress) r.action = Action::ProgressSeen; screen = back(); } + } + if (as.cursor.ok()) cv.icon(as.cursor, c.mx, c.my, std::max(1, gs_ / 2)); // (the game's cursor: the system's is hidden over the window) + return r; + } + +private: + hud::Canvas* cv_ = nullptr; const Assets* as_ = nullptr; const Context* c_ = nullptr; + int gs_ = 2, hover_ = -1, hot_ = -1, itemN_ = 0, selMode_ = 0, tab_ = 0, scroll_ = 0, bindIdx_ = -1, sliderDrag_ = -1; + bool clicked_ = false, inGame_ = false; + double tick_ = 0.0, creditsT_ = 0.0, gameOverT_ = 0.0; + std::string cause_, confirmText_; Action confirmAction_ = Action::None; Screen confirmBack_ = Screen::Title; + std::vector bg_; int bgW_ = 0, bgH_ = 0; + uint32_t white() const { return hud::rgba(255, 255, 255); } + uint32_t grey() const { return hud::rgba(190, 190, 200); } + uint32_t dim() const { return hud::rgba(120, 120, 130); } + uint32_t yellow() const { return hud::rgba(255, 230, 80); } + std::string tr(const std::string& k) const { return c_->tr ? c_->tr(k) : k; } + Screen back() const { return c_->dead ? Screen::GameOverStats : inGame_ ? Screen::Pause : Screen::Title; } // the screen a sub-menu returns to + std::string fmt(const std::string& key, const std::string& a0, const std::string& a1 = std::string()) const { // the game's "$0" / "$1" substitution + std::string s = tr(key); + for (const auto& [tag, val] : {std::pair{"$0", &a0}, {"$1", &a1}}) { const size_t p = s.find(tag); if (p != std::string::npos) s.replace(p, 2, *val); } + return s; + } + int tw(const std::string& s) const { return cv_->text_width(s, gs_); } + int lineH() const { return 11 * gs_; } + + // ---- the parallax background: the sky's gradient, the mountain layers, drifting clouds and twinkling stars (the title and the game over screens) ---- + void background() { + hud::Canvas& cv = *cv_; const Assets& as = *as_; + if (bgW_ != cv.w || bgH_ != cv.h) { + bgW_ = cv.w; bgH_ = cv.h; bg_.assign((size_t)cv.w * cv.h, 0xFF101018u); + hud::Canvas tmp; tmp.px = bg_.data(); tmp.w = cv.w; tmp.h = cv.h; tmp.tiles.assign((size_t)((cv.w + 31) >> 5) * ((cv.h + 31) >> 5), 0); tmp.tw = (cv.w + 31) >> 5; tmp.th = (cv.h + 31) >> 5; + // parallax_colors_main_menu.bmp: a column is a time of day (512 of them), the rows come in bands of 20 (a colour, then a white and a black marker row): + // band 0 the sky's top, 1 its horizon, 2 the clouds, 3 .. the mountain layers from the far one. Column 288 is the night of the title screen. + auto band = [&](int k) -> uint32_t { if (as.sky.empty()) return k == 0 ? 0xFF31253Au : k == 1 ? 0xFF5A4D54u : k == 3 ? 0xFF383C52u : 0xFF292E43u; return as.sky[(size_t)std::min(as.skyH - 1, 20 * k) * as.skyW + std::min(as.skyW - 1, 288)]; }; + const uint32_t top = band(0), bot = band(1); + for (int y = 0; y < cv.h; ++y) { + const double f = std::min(1.0, y / (cv.h * 0.75)); + auto mix = [&](int sh) { return (uint32_t)(((top >> sh) & 255) * (1.0 - f) + ((bot >> sh) & 255) * f); }; + const uint32_t c = 0xFF000000u | mix(16) << 16 | mix(8) << 8 | mix(0); + for (int x = 0; x < cv.w; ++x) bg_[(size_t)y * cv.w + x] = c; + } + auto layer = [&](const hud::Icon& ic, double scale, int dy, uint32_t tint) { // the layers are white masks: tinted with their band's colour + if (!ic.ok()) return; + hud::Icon t2 = ic; for (uint32_t& p : t2.px) p = (p & 0xFF000000u) | (tint & 0xFFFFFFu); + const int w = (int)(ic.w * scale), h = (int)(ic.h * scale); tmp.icon_fit(t2, (cv.w - w) / 2, cv.h - h + dy, w, h); + }; + const double sc = std::max(cv.w / 1024.0, cv.h * 0.75 / 512.0); + layer(as.layer2, sc, (int)(cv.h * 0.08), band(3)); layer(as.layer1, sc, (int)(cv.h * 0.16), band(4)); + } + std::memcpy(cv.px, bg_.data(), bg_.size() * 4); cv.mark_all(); + uint32_t seed = 12345u; // the stars (a fixed scatter over the sky, twinkling) + for (int i = 0; i < 60; ++i) { + seed = seed * 1664525u + 1013904223u; const int x = (int)((seed >> 8) % (uint32_t)cv.w); + seed = seed * 1664525u + 1013904223u; const int y = (int)((seed >> 8) % (uint32_t)std::max(1, cv.h * 6 / 10)); + seed = seed * 1664525u + 1013904223u; const hud::Icon& st = as.stars[(seed >> 8) % 7]; + const double tw = 0.6 + 0.4 * std::sin(tick_ * (1.0 + (i % 5) * 0.3) + i); + if (!st.ok()) continue; + const int s = std::max(1, gs_ / 2); + for (int j = 0; j < st.h; ++j) for (int k = 0; k < st.w; ++k) { const uint32_t p = st.px[(size_t)j * st.w + k]; if (p >> 24) cv.rect(x + k * s, y + j * s, s, s, (p & 0xFFFFFFu) | (uint32_t)((p >> 24) * tw) << 24); } + } + + } + // ---- widgets ---- + bool inRect(int x, int y, int w, int h) const { return c_->mx >= x && c_->mx < x + w && c_->my >= y && c_->my < y + h; } + // a text button centred on cx (or left-aligned at x with centred == false): grey, white when hovered; true when clicked + bool button(int x, int y, const std::string& text, bool centred = true, bool enabled = true) { + const int w = tw(text), h = 8 * gs_, bx = centred ? x - w / 2 : x; + const bool over = enabled && inRect(bx - 2 * gs_, y - 2 * gs_, w + 4 * gs_, h + 4 * gs_); + cv_->text(bx, y, text, !enabled ? dim() : over ? white() : grey(), gs_); + if (over && c_->settings && !c_->settings->cursor) {} + return over && clicked_; + } + void label(int x, int y, const std::string& text, uint32_t col) { cv_->text(x, y, text, col, gs_); } + void panel(int x, int y, int w, int h, bool gray = false) { // a 9-piece box (decorations/9piece0.png: 3-pixel corners) + const hud::Icon& p = gray ? as_->p9gray : as_->p9; + if (!p.ok()) { cv_->rect(x, y, w, h, hud::rgba(20, 20, 24, 230)); cv_->frame(x, y, w, h, hud::rgba(170, 140, 90)); return; } + const int cs = 3, s = gs_; + auto piece = [&](int sx, int sy, int sw, int sh, int dx, int dy, int dw, int dh) { + if (dw <= 0 || dh <= 0) return; + hud::Icon sub; sub.w = sw; sub.h = sh; sub.px.resize((size_t)sw * sh); + for (int j = 0; j < sh; ++j) for (int i = 0; i < sw; ++i) sub.px[(size_t)j * sw + i] = p.px[(size_t)(sy + j) * p.w + sx + i]; + cv_->icon_fit(sub, dx, dy, dw, dh); + }; + const int c = cs * s; + piece(0, 0, cs, cs, x, y, c, c); piece(p.w - cs, 0, cs, cs, x + w - c, y, c, c); piece(0, p.h - cs, cs, cs, x, y + h - c, c, c); piece(p.w - cs, p.h - cs, cs, cs, x + w - c, y + h - c, c, c); + piece(cs, 0, p.w - 2 * cs, cs, x + c, y, w - 2 * c, c); piece(cs, p.h - cs, p.w - 2 * cs, cs, x + c, y + h - c, w - 2 * c, c); + piece(0, cs, cs, p.h - 2 * cs, x, y + c, c, h - 2 * c); piece(p.w - cs, cs, cs, p.h - 2 * cs, x + w - c, y + c, c, h - 2 * c); + piece(cs, cs, p.w - 2 * cs, p.h - 2 * cs, x + c, y + c, w - 2 * c, h - 2 * c); + } + // the text of a toggle "Label: Value" that cycles on a click; returns true when clicked + bool toggle(int x, int y, const std::string& text, const std::string& value) { return button(x, y, text + " " + value, false); } + // a slider: the label, then a bar of `bw` px and the value as "$menu_slider_percentage" (006c6470: x 100, "%"); drag sets `v` in lo..hi + void slider(int x, int y, const std::string& text, float& v, int id, float lo = 0.0f, float hi = 1.0f) { + label(x, y, text, grey()); + const int bx = x + tw(text) + 6 * gs_, bw = 60 * gs_, bh = 6 * gs_, by = y + gs_; + const float f = std::clamp((v - lo) / (hi - lo), 0.0f, 1.0f); + cv_->frame(bx, by, bw, bh, grey()); cv_->rect(bx + 1, by + 1, (int)((bw - 2) * f), bh - 2, hud::rgba(200, 180, 120)); + char pct[16]; std::snprintf(pct, sizeof pct, "%d", (int)std::lround(v * 100.0f)); + label(bx + bw + 4 * gs_, y, fmt("$menu_slider_percentage", pct), grey()); + const bool over = inRect(bx - 2 * gs_, by - 2 * gs_, bw + 4 * gs_, bh + 4 * gs_); + if (over && c_->lmb && (sliderDrag_ < 0 || sliderDrag_ == id)) sliderDrag_ = id; + if (sliderDrag_ == id) { if (c_->lmb) v = lo + (hi - lo) * std::clamp((float)(c_->mx - bx) / (float)(bw - 2), 0.0f, 1.0f); else sliderDrag_ = -1; } + } + void buildLine() { // bottom-left: the build, like the game's "Noita - Build " + const int s = std::max(1, gs_ / 2); + cv_->text(4 * s, cv_->h - 20 * s, "Noita GPU - Build " + c_->build, dim(), s); + if (c_->has_run) cv_->text(4 * s, cv_->h - 11 * s, "Seed: " + std::to_string(c_->seed) + " Mode: " + c_->run_mode + (c_->mods_used ? " Mods used: yes" : ""), dim(), s); + } + void logo(int cy) { // the logo at 0.8 x the GUI scale + const Assets& as = *as_; + if (!as.logo.ok()) { cv_->text(cv_->w / 2 - tw("Noita") , cy, "Noita", yellow(), gs_ * 2); return; } + const double sc = gs_ * 0.8; const int w = (int)(as.logo.w * sc), h = (int)(as.logo.h * sc); // (the wak's logo is 133 x 77: 425 px wide at 1440p like the game's) + cv_->icon_fit(as.logo, (cv_->w - w) / 2, cy - h / 2, w, h); + } + // ---- screens ---- + void title(Result& r) { + logo((int)(cv_->h * 0.3)); + int y = (int)(cv_->h * 0.5); const int cx = cv_->w / 2; + if (c_->has_run && button(cx, y, tr("$menu_continue"))) { screen = Screen::None; r.action = Action::Continue; } + if (c_->has_run) y += lineH(); + if (button(cx, y, tr("$menu_newgame"))) { open(Screen::NewGame); if (c_->settings && c_->settings->online) daily.fetch(); } y += lineH(); + if (button(cx, y, tr("$menu_options"))) open(Screen::Options); y += lineH(); + if (button(cx, y, tr("$menu_mods"))) open(Screen::Mods); y += lineH(); + if (button(cx, y, tr("$menu_releasenotes"))) open(Screen::ReleaseNotes); y += lineH(); + if (button(cx, y, tr("$menu_credits"))) open(Screen::Credits); y += lineH(); + if (button(cx, y, tr("$menu_quit"))) r.action = Action::Quit; + buildLine(); + } + void pause(Result& r) { + const Assets& as = *as_; + int y = 6 * gs_; + if (as.help.ok()) { const double sc = gs_ * 0.9; const int w = (int)(as.help.w * sc), h = (int)(as.help.h * sc); cv_->icon_fit(as.help, (cv_->w - w) / 2, y, w, h); y += h + 4 * gs_; } + const int cx = cv_->w / 2; + cv_->text(cx - tw(tr("$menu_paused")) / 2, y, tr("$menu_paused"), dim(), gs_); y += lineH(); + if (button(cx, y, tr("$menu_continue"))) { screen = Screen::None; r.action = Action::Continue; } y += lineH(); + if (button(cx, y, tr("$menu_newgame"))) confirm(tr("$menu_newgame_areyousure"), Action::None); y += lineH(); + if (button(cx, y, tr("$menu_progress"))) open(Screen::Progress); y += lineH(); + if (button(cx, y, tr("$menu_options"))) open(Screen::Options); y += lineH(); + if (button(cx, y, tr("$menu_mods"))) open(Screen::Mods); y += lineH(); + if (button(cx, y, tr("$menu_releasenotes"))) open(Screen::ReleaseNotes); y += lineH(); + if (button(cx, y, tr("$menu_credits"))) open(Screen::Credits); y += lineH(); + if (button(cx, y, tr("$menu_saveandquit"))) r.action = Action::SaveQuit; + buildLine(); + } + void confirmScreen(Result& r) { // "Are you sure ...?" Yes / No + const int w = 180 * gs_, h = 50 * gs_, x = (cv_->w - w) / 2, y = (cv_->h - h) / 2; + panel(x, y, w, h); + std::vector lines; wrap(confirmText_, w - 12 * gs_, lines); + int ty = y + 6 * gs_; + for (const std::string& l : lines) { label(x + 6 * gs_, ty, l, grey()); ty += 9 * gs_; } + if (button(x + w / 3, y + h - 12 * gs_, tr("$menu_yes"))) { + if (confirmAction_ == Action::None) { open(Screen::NewGame); if (c_->settings && c_->settings->online) daily.fetch(); } // (a new game from the pause menu: the game mode panel) + else { r.action = confirmAction_; screen = confirmBack_; } + } + if (button(x + 2 * w / 3, y + h - 12 * gs_, tr("$menu_no"))) screen = confirmBack_; + } + void wrap(const std::string& text, int width, std::vector& out) const { + std::string line, word; + auto flush = [&]() { out.push_back(line); line.clear(); }; + for (size_t i = 0; i <= text.size(); ++i) { + const char ch = i < text.size() ? text[i] : ' '; + if (ch == '\\' && i + 1 < text.size() && text[i + 1] == 'n') { line += (line.empty() || word.empty() ? "" : " ") + word; word.clear(); flush(); ++i; continue; } + if (ch == '\n') { line += (line.empty() || word.empty() ? "" : " ") + word; word.clear(); flush(); continue; } + if (ch == ' ') { + const std::string cand = line.empty() ? word : line + " " + word; + if (!line.empty() && tw(cand) > width) { flush(); line = word; } else line = cand; + word.clear(); + } else word += ch; + } + if (!line.empty()) out.push_back(line); + } + void newGame(Result& r) { + const Assets& as = *as_; + const std::vector& modes = *c_->modes; + // (006ceb00: the banners in a framed column, the description box beside it taking the rest of a 274-unit panel, the bar below; nothing selected at first) + const int bw = 64 * gs_, bh = 36 * gs_, gap = 2 * gs_, pw = 208 * gs_; + const int colX = (cv_->w - (bw + pw + 4 * gs_)) / 2, top = (int)(cv_->h * 0.3); + const int ph = std::max(4, (int)modes.size()) * (bh + gap) + gap; + label(colX, top - 12 * gs_, tr("$menu_newgame_selectgamemode"), grey()); + panel(colX - gap, top - gap, bw + 2 * gap, ph, true); + for (size_t i = 0; i < modes.size(); ++i) { + const int y = top + (int)i * (bh + gap); + const hud::Icon* bg = &as.bannerNormalBg; const hud::Icon* fg = &as.bannerNormalFg; + if (modes[i].id == "dailyrun") { bg = &as.bannerDailyBg; fg = &as.bannerDailyFg; } + if (modes[i].mod) { bg = modeIcon(modes[i].banner_bg, bg); fg = modeIcon(modes[i].banner_fg, fg); } + if (bg && bg->ok()) cv_->icon_fit(*bg, colX, y, bw, bh); else cv_->rect(colX, y, bw, bh, hud::rgba(60, 50, 70)); + if (fg && fg->ok()) cv_->icon_fit(*fg, colX, y, bw, bh); + const bool over = inRect(colX, y, bw, bh); + if (over) selMode_ = (int)i; // the banner under the mouse is the one described (it stays until another is hovered) + if ((int)i == selMode_ && as.bannerSel.ok()) cv_->icon_fit(as.bannerSel, colX, y, bw, bh); + if (over && clicked_) { selMode_ = (int)i; startMode(r); } + } + // the description box + const int px = colX + bw + 4 * gs_; + panel(px, top - gap, pw, ph, false); + if (selMode_ >= 0 && selMode_ < (int)modes.size()) { + const GameMode& m = modes[selMode_]; + int ty = top + 4 * gs_; + label(px + 6 * gs_, ty, tr(m.name), yellow()); ty += lineH(); + std::vector lines; wrap(tr(m.desc), pw - 12 * gs_, lines); + for (const std::string& l : lines) { label(px + 6 * gs_, ty, l, grey()); ty += 9 * gs_; } + if (m.id == "dailyrun" || m.id == "daily_practice") { + ty += 4 * gs_; + std::string st; + if (!c_->settings || !c_->settings->online) st = tr("$gamemode_dailyrun_desc_offline"); + else if (daily.state.load() == 1) st = "Connecting..."; + else if (daily.state.load() == 3) st = tr("$gamemode_dailyrun_desc_connection_failed"); + else if (daily.state.load() == 2) { + if (m.id == "dailyrun" && last_daily_played == daily.seed) st = tr("$gamemode_dailyrun_desc_played"); + else st = (m.id == "dailyrun" ? "Daily run seed: " : "Daily practice seed: ") + std::to_string(m.id == "dailyrun" ? daily.seed : daily.practice); + } + lines.clear(); wrap(st, pw - 12 * gs_, lines); + for (const std::string& l : lines) { label(px + 6 * gs_, ty, l, grey()); ty += 9 * gs_; } + } + } + // the bar below: the streamer mode, the mod settings + const int by = top + ph + 2 * gs_; + panel(colX - gap, by, bw + pw + 4 * gs_ + 2 * gap, 14 * gs_, true); + { // 006ceb00: "Streamer mode: on/off" is the Twitch integration's switch; switching it on opens the Options at the Streaming tab (to connect) + Settings* S = c_->settings; const bool sm = S && S->streamer_mode; + if (toggle(colX + 4 * gs_, by + 3 * gs_, tr("$menu_streamermode").substr(0, tr("$menu_streamermode").find("$0")), tr(sm ? "$option_on" : "$option_off")) && S) { + S->streamer_mode = !S->streamer_mode; S->save(); + if (S->streamer_mode) { open(Screen::Options); tab_ = 4; } + } + } + if (button(colX + bw + pw - tw(tr("$menu_mods_settings_short")), by + 3 * gs_, tr("$menu_mods_settings_short"), false)) { open(Screen::Options); tab_ = 5; } + if (button(8 * gs_, cv_->h - 14 * gs_, tr("$menu_return"), false)) screen = c_->dead ? Screen::GameOverStats : (inGame_ ? Screen::Pause : Screen::Title); + } + std::map modeIcons_; + const hud::Icon* modeIcon(const std::string& file, const hud::Icon* fallback) { + if (file.empty()) return fallback; + auto it = modeIcons_.find(file); + if (it == modeIcons_.end()) { hud::Icon ic; if (loose_icon) ic = loose_icon(file); it = modeIcons_.emplace(file, std::move(ic)).first; } + return it->second.ok() ? &it->second : fallback; + } +public: + std::function loose_icon; // a mod's banner (mods//files/...), read through the wak's loose root +private: + bool canStart(const GameMode& m) const { + if (m.id == "dailyrun") return daily.state.load() == 2 && last_daily_played != daily.seed; + if (m.id == "daily_practice") return daily.state.load() == 2; + return true; + } + void startMode(Result& r) { + const GameMode& m = (*c_->modes)[selMode_]; + if (!canStart(m)) return; + r.action = Action::StartGame; r.mode_id = m.id; + if (m.id == "dailyrun") { r.seed = daily.seed; r.daily = true; } + else if (m.id == "daily_practice") { r.seed = daily.practice; r.daily_practice = true; } + else { uint32_t s = (uint32_t)GetTickCount() ^ (uint32_t)std::time(nullptr) * 2654435761u; r.seed = s ? s : 1u; } + screen = Screen::None; + } + void options(Result& r) { + const Assets& as = *as_; + static const char* tabs[6] = {"General", "Graphics", "Audio", "Input", "Streaming", "Mod settings"}; + const int pw = std::min(cv_->w - 16 * gs_, 280 * gs_), px = (cv_->w - pw) / 2, py = 24 * gs_, ph = cv_->h - py - 24 * gs_; + int tx = px + 4 * gs_; + for (int i = 0; i < 6; ++i) { + const int tw0 = 44 * gs_, th = 10 * gs_; + const bool sel = tab_ == i, over = inRect(tx, py - th, tw0, th); + const hud::Icon& ic = sel ? as.tabSel : (over ? as.tabHover : as.tab); + if (ic.ok()) cv_->icon_fit(ic, tx, py - th - (sel ? gs_ : 0), tw0, th + (sel ? gs_ : 0)); else cv_->frame(tx, py - th, tw0, th, grey()); + const std::string t = tabs[i]; const int s = std::max(1, gs_ * 2 / 3); + cv_->text(tx + (tw0 - cv_->text_width(t, s)) / 2, py - th + 3 * gs_ - (sel ? gs_ : 0), t, sel ? white() : grey(), s); + if (over && clicked_) { tab_ = i; scroll_ = 0; bindIdx_ = -1; } + tx += tw0 + gs_; + } + panel(px, py, pw, ph); + Settings& S = *c_->settings; + int x = px + 10 * gs_, y = py + 8 * gs_ - scroll_; + auto header = [&](const char* t) { label(x, y, t, dim()); y += lineH(); }; + auto onoff = [&](bool b) { return tr(b ? "$option_on" : "$option_off"); }; + auto opt = [&](const char* key, bool& b) { if (toggle(x, y, tr(key).substr(0, tr(key).find("$0")), onoff(b))) b = !b; y += lineH(); }; + if (tab_ == 0) { + header("WINDOW"); + if (toggle(x, y, "Window mode:", S.window_mode == 0 ? "Windowed" : "Fullscreen")) S.window_mode = 1 - S.window_mode; y += lineH(); + label(x, y, "Resolution: " + std::to_string(c_->w) + " x " + std::to_string(c_->h), grey()); y += lineH(); + opt("$menuoptions_vsync", S.vsync); + y += 4 * gs_; header("COMPATIBILITY"); + opt("$menuoptions_application_rendered_cursor", S.cursor); + y += 4 * gs_; header("REPLAY RECORDER"); + opt("$menuoptions_replayrecorder", S.replay_recorder); + y += 4 * gs_; header("ONLINE"); + if (toggle(x, y, "Online features:", onoff(S.online))) S.online = !S.online; y += lineH(); + y += 4 * gs_; header("MISCELLANEOUS"); + label(x, y, tr("$menuoptions_language"), grey()); y += lineH(); + if (button(x, y, tr("$menuoptions_resetsave"), false)) confirm(tr("$menuoptions_resetsave_areyousure"), Action::ResetProgress); + y += lineH(); + } else if (tab_ == 1) { + header(tr("$menuoptions_heading_rendering").c_str()); + slider(x, y, tr("$menuoptions_brightness"), S.brightness, 1, Settings::kBrightMin, Settings::kBrightMax); y += lineH(); + slider(x, y, tr("$menuoptions_contrast"), S.contrast, 2, Settings::kContrastMin, Settings::kContrastMax); y += lineH(); + slider(x, y, tr("$menuoptions_gamma"), S.gamma, 3, Settings::kGammaMin, Settings::kGammaMax); y += lineH(); + slider(x, y, tr("$menuoptions_cosmeticparticlecoeff"), S.cosmetic_particles, 4, Settings::kCosmeticMin, 1.0f); y += lineH(); + r.action = Action::ApplySettings; // (the picture follows the sliders as they move) + y += 4 * gs_; header(tr("$menuoptions_heading_userinterface_graphics").c_str()); + opt("$menuoptions_damagenumbers", S.damage_numbers); + y += 4 * gs_; header(tr("$menuoptions_heading_accessibility").c_str()); + slider(x, y, tr("$menuoptions_screenshake_intensity"), S.screenshake, 5); y += lineH(); + } else if (tab_ == 2) { + header(tr("$menuoptions_heading_volume").c_str()); + slider(x, y, tr("$menuoptions_musicvolume"), S.music, 6); y += lineH(); + slider(x, y, tr("$menuoptions_soundsvolume"), S.sfx, 7); y += lineH(); + } else if (tab_ == 3) { + header(tr("$menuoptions_heading_controls").c_str()); + for (size_t i = 0; i < S.keys.size(); ++i) { + KeyBind& k = S.keys[i]; + const std::string v = bindIdx_ == (int)i ? "..." : keyName(k.key); + if (toggle(x, y, std::string(k.name) + ":", v)) bindIdx_ = (int)i; + y += 9 * gs_; + } + if (bindIdx_ >= 0 && c_->key_pressed) { if (c_->key_pressed != VK_ESCAPE) S.keys[bindIdx_].key = c_->key_pressed; bindIdx_ = -1; } + label(x, y, tr("$menuoptions_reset_keyboard"), dim()); y += lineH(); + if (button(x, y, tr("$menuoptions_configurecontrols_reset_all"), false)) { Settings d; S.keys = d.keys; } y += lineH(); + y += 4 * gs_; header(tr("$menuoptions_heading_userinterface_mouse").c_str()); + opt("$menuoptions_ui_inventory_icons_always_clickable", S.click_switches_item); + opt("$menuoptions_showhoverinfonexttomouse", S.hover_info_at_mouse); + y += 4 * gs_; header(tr("$menuoptions_heading_userinterface_input").c_str()); + opt("$menuoptions_pause_the_game_when_unfocused", S.pause_unfocused); + } else if (tab_ == 4) { // 006d5620: TWITCH CHANNEL - Connect / Disconnect (the OAuth sign-in and the chat votes), then the vote settings; the switch itself is the streamer mode + header(tr("$menuoptions_heading_streaming_integration").c_str()); + if (toggle(x, y, tr("$menu_streamermode").substr(0, tr("$menu_streamermode").find("$0")), onoff(S.streamer_mode))) S.streamer_mode = !S.streamer_mode; y += lineH(); + button(x, y, tr("$menu_streaming_connect_twitch"), false, false); y += lineH(); + std::vector lines; wrap("The Twitch sign-in and the chat votes are not part of this build (to-do #47).", pw - 20 * gs_, lines); + for (const std::string& l : lines) { label(x, y, l, dim()); y += 9 * gs_; } + } else { + header("MODS"); + if (!c_->mods || c_->mods->empty()) label(x, y, "No mods enabled.", grey()); + for (const std::string& m : c_->mods ? *c_->mods : std::vector()) { label(x, y, m + " - " + tr("$menu_mods_nosettings"), grey()); y += 9 * gs_; } + } + scrollBy(c_->wheel * 12 * gs_, std::max(0, y + scroll_ - (py + ph) + 20 * gs_)); + if (button(8 * gs_, cv_->h - 14 * gs_, tr("$menu_applyandreturn"), false)) { S.save(); r.action = Action::ApplySettings; screen = back(); } + } + void scrollBy(int d, int maxScroll) { scroll_ = std::clamp(scroll_ + d, 0, std::max(0, maxScroll)); } + static std::string keyName(int k) { + if (k >= 'A' && k <= 'Z') return std::string(1, (char)k); + if (k >= '0' && k <= '9') return std::string(1, (char)k); + switch (k) { case VK_TAB: return "Tab"; case VK_SPACE: return "Space"; case VK_LEFT: return "Left"; case VK_RIGHT: return "Right"; case VK_UP: return "Up"; case VK_DOWN: return "Down"; + case VK_SHIFT: return "Shift"; case VK_CONTROL: return "Ctrl"; case VK_RETURN: return "Enter"; case VK_LBUTTON: return "LMB"; case VK_RBUTTON: return "RMB"; default: break; } + char b[16]; std::snprintf(b, sizeof b, "key %d", k); return b; + } + void textScreen(const std::string& titleText, const std::vector& lines, bool autoScroll) { + const int pw = std::min(cv_->w - 16 * gs_, 300 * gs_), px = (cv_->w - pw) / 2, py = 20 * gs_, ph = cv_->h - py - 24 * gs_; + label(px, py - 10 * gs_, titleText, grey()); + panel(px, py, pw, ph); + const int s = std::max(1, gs_ * 2 / 3), lh = 8 * s + 2; + const int total = (int)lines.size() * lh; + if (autoScroll) { creditsT_ += 1.0; scroll_ = std::clamp((int)(creditsT_ * 0.5 * s) - ph / 2, 0, std::max(0, total - ph + 20 * gs_)); scrollBy(c_->wheel * 3 * lh, total - ph + 20 * gs_); } + else scrollBy(c_->wheel * 3 * lh, total - ph + 20 * gs_); + int y = py + 6 * gs_ - scroll_; + for (const std::string& l : lines) { + uint32_t col = grey(); std::string t = l; + if (!t.empty() && t[0] == '#') { col = yellow(); t = t.substr(1); } + else if (!t.empty() && t[0] == '*') { col = white(); t = t.substr(1); } + std::vector parts; // (a long line wraps at the box's width) + std::string line, word; + for (size_t i = 0; i <= t.size(); ++i) { + const char ch = i < t.size() ? t[i] : ' '; + if (ch == ' ') { const std::string cand = line.empty() ? word : line + " " + word; if (!line.empty() && cv_->text_width(cand, s) > pw - 12 * gs_) { parts.push_back(line); line = word; } else line = cand; word.clear(); } + else word += ch; + } + parts.push_back(line); + for (size_t k = 0; k < parts.size(); ++k) { + if (y + lh >= py + 3 * gs_ && y < py + ph - 3 * gs_) cv_->text(px + 6 * gs_ + (k ? 8 * s : 0), y, parts[k], k ? grey() : col, s); + y += lh; + } + } + if (button(8 * gs_, cv_->h - 14 * gs_, tr("$menu_return"), false)) screen = back(); + } + void releaseNotes() { + std::vector lines; + if (!c_->release_notes || c_->release_notes->empty()) lines.push_back("No release notes beside the executable (release_notes.txt)."); + else { std::string cur; for (char ch : *c_->release_notes) { if (ch == '\n') { lines.push_back(cur); cur.clear(); } else if (ch != '\r') cur += ch; } if (!cur.empty()) lines.push_back(cur); } + textScreen(tr("$menu_releasenotes"), lines, false); + } + void credits() { + std::vector lines; + std::string cur; for (char ch : as_->credits) { if (ch == '\n') { lines.push_back(cur); cur.clear(); } else if (ch != '\r') cur += ch; } + if (!cur.empty()) lines.push_back(cur); + std::vector ours = {"*NOITA GPU", "A Vulkan rebuild of Noita's engine.", "", "*ORIGINAL GAME", "Noita by Nolla Games - the art, the data and the design below are theirs.", ""}; + ours.insert(ours.end(), lines.begin(), lines.end()); + textScreen(tr("$menu_credits"), ours, true); + } + // The Mods screen: the mods of mods/ in load order, a click on the name enables / disables (the game's: "Click mod name to enable/disable the mod"), Move up / + // Move down reorder, the list is written at once (save/mod_config.xml, the game's layout) and applies at the next start - "Restart with enabled mods active" + // relaunches the run, "Start a new game with enabled mods active" opens the game modes. A game-mode mod is played from the New game menu, not enabled here. + void mods(Result& r) { + const int pw = std::min(cv_->w - 16 * gs_, 300 * gs_), px = (cv_->w - pw) / 2, py = 20 * gs_, ph = cv_->h - py - 24 * gs_; + label(px, py - 10 * gs_, tr("$menu_mods"), grey()); + panel(px, py, pw, ph); + const int s = std::max(1, gs_ * 2 / 3), lh = 8 * s + 2; + int x = px + 6 * gs_, y = py + 6 * gs_ - scroll_; + std::vector help; wrap(tr("$menu_mods_help"), pw - 12 * gs_, help); + for (const std::string& l : help) { cv_->text(x, y, l, dim(), s); y += lh; } + y += lh; + cv_->text(x, y, tr("$menu_mods_availablemods"), white(), s); y += lh + 2 * gs_; + std::vector* mods = c_->all_mods; + if (!mods || mods->empty()) { cv_->text(x, y, tr("$menu_mods_availablemods_none"), grey(), s); y += lh; } + std::string tip; int tipY = 0; + for (size_t i = 0; mods && i < mods->size(); ++i) { + ModInfo& m = (*mods)[i]; + const std::string box = m.game_mode ? " - " : m.enabled ? "[x]" : "[ ]"; + const std::string line = box + " " + (m.name.empty() ? m.id : m.name); + const int w = cv_->text_width(line, s); + const bool over = inRect(x - gs_, y - gs_, w + 2 * gs_, lh); + cv_->text(x, y, line, m.game_mode ? dim() : over || m.enabled ? white() : grey(), s); + if (over && clicked_ && !m.game_mode) { m.enabled = !m.enabled; r.action = Action::ApplyMods; } + if (over) { tip = m.desc + (m.game_mode ? tr("$menu_mods_customactivation") : ""); tipY = y; } + const int bx = px + pw - 60 * gs_; // Move up / Move down (small buttons on the row) + auto sbtn = [&](int sx, const std::string& t2) { const int w2 = cv_->text_width(t2, s); const bool ov = inRect(sx - gs_, y - gs_, w2 + 2 * gs_, lh); cv_->text(sx, y, t2, ov ? white() : dim(), s); return ov && clicked_; }; + if (i > 0 && sbtn(bx, tr("$menu_mods_moveup"))) { std::swap((*mods)[i], (*mods)[i - 1]); r.action = Action::ApplyMods; } + if (i + 1 < mods->size() && sbtn(bx + 26 * gs_, tr("$menu_mods_movedown"))) { std::swap((*mods)[i], (*mods)[i + 1]); r.action = Action::ApplyMods; } + y += lh; + } + y += lh; + cv_->text(x, y, tr("$menu_mods_help_paused"), dim(), s); y += lh + gs_; + if (button(x, y, tr("$menu_mods_openmodfolder"), false)) r.action = Action::OpenModFolder; y += lineH(); + if (c_->has_run && button(x, y, tr("$menu_mods_restart"), false)) { r.action = Action::RestartMods; screen = Screen::None; } if (c_->has_run) y += lineH(); + if (button(x, y, tr("$menu_mods_newgame"), false)) { open(Screen::NewGame); if (c_->settings && c_->settings->online) daily.fetch(); } y += lineH(); + if (!tip.empty()) { // the mod's description beside the list + std::vector lines; wrap(tip, 110 * gs_, lines); + const int bw = 116 * gs_, bh = (int)lines.size() * lh + 6 * gs_, bx = std::min(cv_->w - bw - 2 * gs_, c_->mx + 6 * gs_), by = std::min(cv_->h - bh - 2 * gs_, tipY); + panel(bx, by, bw, bh, true); + int ly = by + 3 * gs_; for (const std::string& l : lines) { cv_->text(bx + 3 * gs_, ly, l, grey(), s); ly += lh; } + } + scrollBy(c_->wheel * 3 * lh, std::max(0, y + scroll_ - (py + ph) + 20 * gs_)); + if (button(8 * gs_, cv_->h - 14 * gs_, tr("$menu_return"), false)) screen = back(); + } + // The Progress screen (006dfe40): three grids - the perks (9 columns), the spells (12) and the enemies (9) - each cell a grid_box with the icon when the + // perk / spell was picked up (the perk_picked_ / action_ persistent flag) or the enemy killed, grid_box_unknown with icon_unknown otherwise; an entry + // new since the screen was last seen pulses with grid_highlight_new. Hovering shows the name and the description, "???" when unknown (the enemies: Kills / + // Killed by). Below: the endings discovered and the secrets found. + void progress(Result& r) { + const Assets& as = *as_; + const int pw = std::min(cv_->w - 16 * gs_, 300 * gs_), px = (cv_->w - pw) / 2, py = 20 * gs_, ph = cv_->h - py - 24 * gs_; + label(px, py - 10 * gs_, tr("$menu_progress"), grey()); + panel(px, py, pw, ph); + const int s = std::max(1, gs_ * 2 / 3), lh = 8 * s + 2; + const int cell = (as.gridBox.ok() ? as.gridBox.w : 20) * gs_, pitch = cell + gs_; + int x = px + 6 * gs_, y = py + 6 * gs_ - scroll_; + const ProgressItem* hot = nullptr; bool hotEnemy = false; + auto grid = [&](const char* header, const std::vector* items, int cols, bool enemies) { + if (!items) return; + int known = 0; for (const ProgressItem& it : *items) known += it.known; + cv_->text(x, y, tr(header) + " " + std::to_string(known) + " / " + std::to_string(items->size()), white(), s); y += lh + gs_; + cols = std::max(1, std::min(cols, (pw - 12 * gs_) / pitch)); + for (size_t i = 0; i < items->size(); ++i) { + const ProgressItem& it = (*items)[i]; + const int cx = x + (int)(i % cols) * pitch, cy = y + (int)(i / cols) * pitch; + if (cy + cell < py + 3 * gs_ || cy > py + ph - 3 * gs_) continue; // (scrolled out of the box) + const hud::Icon& box = it.known || !as.gridBoxUnknown.ok() ? as.gridBox : as.gridBoxUnknown; + if (box.ok()) cv_->icon_fit(box, cx, cy, cell, cell); else cv_->frame(cx, cy, cell, cell, it.known ? grey() : dim()); + const hud::Icon* ic = it.known ? iconOf(it.icon) : nullptr; + if (!ic || !ic->ok()) ic = &as.iconUnknown; + if (ic->ok()) { const int iw = std::min(cell - 4 * gs_, ic->w * gs_), ih = std::min(cell - 4 * gs_, ic->h * gs_); cv_->icon_fit(*ic, cx + (cell - iw) / 2, cy + (cell - ih) / 2, iw, ih); } + if (it.is_new && it.known && as.gridNew.ok()) { // (006df000: the highlight's alpha follows a sine of the frame) + hud::Icon hi = as.gridNew; const double a = 0.5 + 0.5 * std::sin(tick_ * 5.0 + i * 0.7); + for (uint32_t& p : hi.px) p = (p & 0xFFFFFFu) | (uint32_t)((p >> 24) * a) << 24; + cv_->icon_fit(hi, cx, cy, cell, cell); + } + if (inRect(cx, cy, cell, cell) && cy >= py && cy + cell <= py + ph) { hot = ⁢ hotEnemy = enemies; } + } + y += ((int)items->size() + cols - 1) / cols * pitch + lh; + }; + grid("$progress_perks", c_->perks, 9, false); + grid("$progress_actions", c_->actions, 12, false); + grid("$progress_enemies", c_->enemies, 9, true); + if (c_->ending0 || c_->ending1) { cv_->text(x, y, tr(c_->ending0 && c_->ending1 ? "$progress_ending01" : c_->ending0 ? "$progress_ending0" : "$progress_ending1"), grey(), s); y += lh; } + cv_->text(x, y, fmt("$progress_completed", std::to_string(c_->secrets_found), ""), grey(), s); y += lh; + if (hot) { // the information box of the cell under the mouse + std::vector lines; + if (!hot->known) lines.push_back(tr("$progress_unknown")); + else { + lines.push_back(hot->is_new ? fmt("$progress_new", tr(hot->name)) : tr(hot->name)); + if (hotEnemy) { lines.push_back(fmt("$progress_kills", std::to_string(hot->kills))); lines.push_back(fmt("$progress_killed_by", std::to_string(hot->killed_by))); } + else wrap(tr(hot->desc), 110 * gs_, lines); + } + const int bw = 116 * gs_, bh = (int)lines.size() * lh + 6 * gs_, bx = std::min(cv_->w - bw - 2 * gs_, c_->mx + 8 * gs_), by = std::min(cv_->h - bh - 2 * gs_, c_->my + 8 * gs_); + panel(bx, by, bw, bh, true); + int ly = by + 3 * gs_; for (size_t k = 0; k < lines.size(); ++k) { cv_->text(bx + 3 * gs_, ly, lines[k], k == 0 ? white() : grey(), s); ly += lh; } + } + scrollBy(c_->wheel * 3 * lh, std::max(0, y + scroll_ - (py + ph) + 20 * gs_)); + if (button(8 * gs_, cv_->h - 14 * gs_, tr("$menu_return"), false)) { r.action = Action::ProgressSeen; screen = back(); } + } + const hud::Icon* iconOf(const std::string& file) { return modeIcon(file, nullptr); } // (any image of the wak or a mod, cached) + void gameOver(Result& r) { + gameOverT_ += 1.0 / 60.0; + const Assets& as = *as_; + const double fade = std::min(1.0, gameOverT_ / 1.5); + const int cx = cv_->w / 2; int y = (int)(cv_->h * 0.18); + if (as.gameOver.ok()) { const int w = as.gameOver.w * gs_ / 1, h = as.gameOver.h * gs_; const int ww = std::min(w, cv_->w - 16 * gs_), hh = h * ww / std::max(1, w); cv_->icon_fit(as.gameOver, cx - ww / 2, y - (int)(10 * gs_ * (1.0 - fade)), ww, hh); y += hh + 4 * gs_; } + if (gameOverT_ < 1.0) return; + std::string cause = tr("$stat_cause_of_death"); const size_t p = cause.find("$0"); if (p != std::string::npos) cause.replace(p, 2, cause_); + cv_->text(cx - tw(cause) / 2, y, cause, grey(), gs_); y += lineH(); + if (button(cx, y, tr("$menugameover_nextbutton"))) screen = Screen::GameOverStats; + (void)r; + } + void gameOverStats(Result& r) { + const Assets& as = *as_; + const int cx = cv_->w / 2; int y = (int)(cv_->h * 0.18); + if (as.gameOver.ok()) { const int w = as.gameOver.w * gs_, h = as.gameOver.h * gs_; const int ww = std::min(w, cv_->w - 16 * gs_), hh = h * ww / std::max(1, w); cv_->icon_fit(as.gameOver, cx - ww / 2, y, ww, hh); y += hh - 6 * gs_; } + const int pw = 192 * gs_, px = cx - pw / 2, ph = std::min(200 * gs_, cv_->h - y - 4 * gs_); // (006e51e0: the box of 192 x 200 units under the title) + panel(px, y, pw, ph); + int ty = y + 12 * gs_; const int lx = px + 36 * gs_; + auto row = [&](const std::string& key, const std::string& val, const std::string& best) { + label(lx, ty, tr(key), grey()); + cv_->text(lx + tw(tr(key)) + 3 * gs_, ty, val, yellow(), gs_); + if (!best.empty()) { std::string b = tr("$stat_best"); const size_t p = b.find("$0"); if (p != std::string::npos) b.replace(p, 2, best); cv_->text(lx + tw(tr(key)) + tw(val) + 6 * gs_, ty, b, dim(), gs_); } + ty += 9 * gs_; + }; + std::string cause = tr("$stat_cause_of_death"); const size_t p = cause.find("$0"); if (p != std::string::npos) cause.replace(p, 2, cause_); + label(lx, ty, cause, grey()); ty += 9 * gs_; + row("$stat_modsenabled", tr(c_->mods_used ? "$menu_yes" : "$menu_no"), ""); + auto timeStr = [](double s) { char b[32]; const int t = (int)s; std::snprintf(b, sizeof b, "%d:%02d:%02d", t / 3600, (t / 60) % 60, t % 60); return std::string(b); }; + row("$stat_gold", std::to_string(c_->stats.gold), std::to_string(c_->best.gold)); + row("$stat_time", timeStr(c_->stats.playtime), timeStr(c_->best.playtime)); + row("$stat_depth", std::to_string(c_->stats.depth) + "m", std::to_string(c_->best.depth) + "m"); + row("$stat_places_visited", std::to_string(c_->stats.places), std::to_string(c_->best.places)); + row("$stat_enemies_slain", std::to_string(c_->stats.kills), std::to_string(c_->best.kills)); + row("$stat_max_hp", std::to_string(c_->stats.maxhp), std::to_string(c_->best.maxhp)); + row("$stat_items_found", std::to_string(c_->stats.items), std::to_string(c_->best.items)); + row("$stat_total_deaths", std::to_string(c_->total_deaths), ""); + row("$stat_total_wins", std::to_string(c_->total_wins), ""); + ty += 8 * gs_; + if (button(cx, ty, tr("$menugameover_newgame"))) { open(Screen::NewGame); if (c_->settings && c_->settings->online) daily.fetch(); } ty += lineH(); + if (button(cx, ty, tr("$menugameover_savereplay"), true, c_->replay_available)) r.action = Action::SaveReplay; ty += lineH(); // (006e51e0: with the replay recorder on, the clip editor over the game over) + if (button(cx, ty, tr("$menu_progress"))) open(Screen::Progress); ty += lineH(); + if (button(cx, ty, tr("$menugameover_quit"))) r.action = Action::Quit; + } +}; + +} // namespace menu diff --git a/src/audio/audio_ipc.h b/src/audio/audio_ipc.h index 1f75ad4..7e0d427 100644 --- a/src/audio/audio_ipc.h +++ b/src/audio/audio_ipc.h @@ -9,7 +9,7 @@ constexpr int kRing = 1024; // commands constexpr int kHandles = 4096; // kept instances (handle % kHandles) constexpr int kName = 120; -enum Op : int32_t { None = 0, PlayOneShot, Start, Move, SetParam, Stop, StopNow, Listener, SetVolume, Quit, Release }; +enum Op : int32_t { None = 0, PlayOneShot, Start, Move, SetParam, Stop, StopNow, Listener, SetVolume, Quit, Release, Pause }; // Pause: value 1 pauses every kept instance, 0 resumes them (the game's pause menu) struct Cmd { int32_t op = None; diff --git a/src/audio/fmod_audio.cpp b/src/audio/fmod_audio.cpp index 5751d23..ad3106e 100644 --- a/src/audio/fmod_audio.cpp +++ b/src/audio/fmod_audio.cpp @@ -59,13 +59,13 @@ bool FmodAudio::push(int op, int handle, float x, float y, float value, const st return true; } -void FmodAudio::play(const std::string& event, float x, float y, float volume) { if (!event.empty()) push(PlayOneShot, 0, x, y, volume, event); } +void FmodAudio::play(const std::string& event, float x, float y, float volume) { if (!event.empty()) push(PlayOneShot, 0, x, y, volume * sfx_gain_, event); } int FmodAudio::start(const std::string& event, float x, float y, float volume) { if (event.empty() || !ok()) return 0; const int h = next_handle_++; if (next_handle_ >= kHandles * 1000) next_handle_ = 1; - if (!push(Start, h, x, y, volume, event)) return 0; + if (!push(Start, h, x, y, volume * (event.find("music") != std::string::npos ? music_gain_ : sfx_gain_), event)) return 0; sh_->playing[h % kHandles] = 1; return h; } @@ -73,6 +73,7 @@ int FmodAudio::start(const std::string& event, float x, float y, float volume) { void FmodAudio::move(int handle, float x, float y) { if (handle > 0) push(Move, handle, x, y, 0, ""); } void FmodAudio::set_parameter(int handle, const std::string& name, float value) { if (handle > 0) push(SetParam, handle, 0, 0, value, name); } void FmodAudio::set_volume(int handle, float volume) { if (handle > 0) push(SetVolume, handle, 0, 0, volume, ""); } +void FmodAudio::set_paused(bool paused) { push(Pause, 0, 0, 0, paused ? 1.0f : 0.0f, ""); } void FmodAudio::release(int handle) { if (handle > 0) push(Release, handle, 0, 0, 0, ""); } void FmodAudio::stop(int handle, bool immediate) { if (handle > 0) push(immediate ? StopNow : Stop, handle, 0, 0, 0, ""); } bool FmodAudio::playing(int handle) const { return handle > 0 && ok() && sh_->playing[handle % kHandles] != 0; } diff --git a/src/audio/fmod_audio.h b/src/audio/fmod_audio.h index 8a91f0a..d1db1aa 100644 --- a/src/audio/fmod_audio.h +++ b/src/audio/fmod_audio.h @@ -18,11 +18,14 @@ public: bool ok() const; // the host is up and has loaded the banks // a one-shot event at a world position (pixels): "game_effect/teleport/tick" or "event:/game_effect/teleport/tick" void play(const std::string& event, float x, float y, float volume = 1.0f); + void set_gains(float sfx, float music) { sfx_gain_ = sfx; music_gain_ = music; } // the Options' volumes: one-shots and started events ("music/" events: the music gain) + float sfx_gain_ = 1.0f, music_gain_ = 1.0f; // a kept instance (loops, sounds that follow an entity): handle > 0 int start(const std::string& event, float x, float y, float volume = 1.0f); void move(int handle, float x, float y); void set_parameter(int handle, const std::string& name, float value); void set_volume(int handle, float volume); + void set_paused(bool paused); // every started instance holds (the pause menu); one-shots still play void stop(int handle, bool immediate = false); void release(int handle); // the instance plays to its end on its own; the handle is free bool playing(int handle) const; diff --git a/src/entity/potion_spray.h b/src/entity/potion_spray.h new file mode 100644 index 0000000..d898c6f --- /dev/null +++ b/src/entity/potion_spray.h @@ -0,0 +1,42 @@ +#pragma once +#include "entity/material_mix.h" +#include "core/materials.h" + +namespace ng { +struct PotionSpray { + double speed = 1, minimum = .5; + bool bunch = false, skip_static = false, leak_gas = false; + int count = 5; +}; + +// 00bfdd10: one attempt per material, or throw_how_many on the initial press. +// Velocities are pixels/second here; the GPU transport converts to pixels/tick. +template +int potion_spray(std::vector& parts, const MaterialDb& db, const PotionSpray& cfg, + double capacity, double aim_x, double aim_y, double holder_vx, + bool pressed, Random random, Emit emit) { + if (capacity <= 0 || (cfg.bunch && !pressed)) return 0; + int attempts = 0; + for (auto& part : parts) { + if (part.n <= 0 || (cfg.bunch && (int)part.n <= 0)) continue; + const Material* m = db.find(part.mat); + if (!m) continue; + if (!cfg.bunch && cfg.skip_static && (m->type == CellType::Solid || + (m->type == CellType::Liquid && attr_int(*m, "liquid_static", 0)))) continue; + const double speed = cfg.speed * std::max(cfg.minimum, std::max(1, (int)part.n) / capacity); + const int count = cfg.bunch ? std::max(0, cfg.count) : 1; + for (int i = 0; i < count && part.n > 0; ++i) { + const double angle = (random() * 2 - 1) * (cfg.bunch ? .25 : .05); + const double c = std::cos(angle), s = std::sin(angle), length = std::hypot(aim_x, aim_y); + double vx = length > 0 ? (aim_x * c - aim_y * s) / length * speed : 0; + double vy = length > 0 ? (aim_x * s + aim_y * c) / length * speed : 0; + vx += holder_vx; + if (!cfg.bunch && cfg.leak_gas && m->type == CellType::Gas) { vx = 0; vy = -10; } + emit(*m, vx, vy); + part.n -= 1; // Native spends contents even when the particle pool is full. + ++attempts; + } + } + return attempts; +} +} // namespace ng diff --git a/src/lua/host_natives.cpp b/src/lua/host_natives.cpp index 55ea762..98c3800 100644 --- a/src/lua/host_natives.cpp +++ b/src/lua/host_natives.cpp @@ -90,6 +90,9 @@ int n_GameGetRealWorldTimeSinceStarted(LuaHost&, lua_State* L) { lua_pushnumber(L, std::chrono::duration(std::chrono::steady_clock::now() - t0).count()); return 1; } +// the game modes of the menu (src/app/menu.h): NG_DAILY=1 a daily run, 2 a daily practice run (the seed came from the backend) +int n_GameIsDailyRun(LuaHost&, lua_State* L) { const char* d = std::getenv("NG_DAILY"); lua_pushboolean(L, d && std::string(d) == "1"); return 1; } +int n_GameIsDailyRunOrPractice(LuaHost&, lua_State* L) { const char* d = std::getenv("NG_DAILY"); lua_pushboolean(L, d && *d); return 1; } int n_GetDailyPracticeRunSeed(LuaHost&, lua_State* L) { // the day's seed: the UTC date const std::time_t t = std::time(nullptr); std::tm tm{}; @@ -344,7 +347,7 @@ void register_host_natives(LuaHost& h) { {"SessionNumbersGetValue", n_SessionNumbersGetValue}, {"SessionNumbersSetValue", n_SessionNumbersSetValue}, {"SessionNumbersSave", n_Nop}, {"MagicNumbersGetValue", n_MagicNumbersGetValue}, {"ModMagicNumbersFileAdd", n_ModMagicNumbersFileAdd}, {"GameGetDateAndTimeUTC", n_GameGetDateAndTimeUTC}, {"GameGetRealWorldTimeSinceStarted", n_GameGetRealWorldTimeSinceStarted}, {"GetDailyPracticeRunSeed", n_GetDailyPracticeRunSeed}, - {"GameIsDailyRun", n_False}, {"GameIsDailyRunOrDailyPracticeRun", n_False}, {"GameIsBetaBuild", n_False}, {"DebugGetIsDevBuild", n_False}, {"GameIsIntroPlaying", n_False}, + {"GameIsDailyRun", n_GameIsDailyRun}, {"GameIsDailyRunOrDailyPracticeRun", n_GameIsDailyRunOrPractice}, {"GameIsBetaBuild", n_False}, {"DebugGetIsDevBuild", n_False}, {"GameIsIntroPlaying", n_False}, {"DebugBiomeMapGetFilename", n_DebugBiomeMapGetFilename}, // release-build behaviour: these do nothing outside the developers' builds (EntitySave, trailer mode, the test player) or report to Steam {"EntitySave", n_Nop}, {"DebugEnableTrailerMode", n_Nop}, {"Debug_SaveTestPlayer", n_Nop}, {"GameGiveAchievement", n_Nop}, {"AutosaveDisable", n_Nop}, diff --git a/src/lua/lua_host.cpp b/src/lua/lua_host.cpp index fd55ea9..cd99991 100644 --- a/src/lua/lua_host.cpp +++ b/src/lua/lua_host.cpp @@ -520,7 +520,9 @@ LuaHost::~LuaHost() { if (L_) lua_close(L_); } -const std::string& LuaHost::translate(const std::string& key) { +const std::string& LuaHost::translate(const std::string& key) { return translate_in(wak_, loose_dir, key); } + +const std::string& LuaHost::translate_in(const Wak& wak_, const std::string& loose_dir, const std::string& key) { static const std::string none; if (!texts_loaded_) { texts_loaded_ = true; diff --git a/src/lua/lua_host.h b/src/lua/lua_host.h index dc8e915..bcae614 100644 --- a/src/lua/lua_host.h +++ b/src/lua/lua_host.h @@ -171,6 +171,8 @@ public: bool buffer_world_writes = false, wrote_world = false; std::set removed_run_flags; std::string loose_dir; // the game folder next to data.wak (the translations are loose files there) + // translate() without a host (the menus before a run): the wak and the folder beside it + static const std::string& translate_in(const Wak& wak, const std::string& loose_dir, const std::string& key); std::map stats, biome_stats_base; // StatsGetValue / StatsBiomeGetValue (the host counts enemies_killed...; StatsResetBiome starts the biome counters over) int next_entity_id = 1000; struct GenComp { int ent = 0; int index = 0; std::string type; }; // index >= 0: the spawn's recorded component; -1 - k: the k-th component of the file @@ -194,8 +196,8 @@ private: std::vector actions_; size_t actions_len_ = (size_t)-1; bool tried_actions_ = false; - std::map texts_; - bool texts_loaded_ = false; + static inline std::map texts_; // (one table for every VM and the menus) + static inline bool texts_loaded_ = false; }; } // namespace ng