diff --git a/README.md b/README.md index 928209e..c1bafec 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ | `Space` ×2(ダブルタップ) | 飛行モード / 重力モード切替 | | `左クリック` | ブロックを壊す / モブを攻撃 | | `右クリック` | ブロックを置く(弓装備中はチャージ→離して発射) | -| `1` 〜 `6` | ブロック種別選択(石 / 草 / 土 / 砂 / 雪 / 木材) | -| `9` | 弓を選択(ホットバー最後のスロットに常備) | +| `1` 〜 `9` | ホットバースロット選択 | +| `C` | クラフトメニュー表示 / 非表示 | +| `↑` `↓` | クラフトレシピ選択 | +| `Enter` | 選択中レシピをクラフト | | `Esc` | カーソル解放 | --- @@ -48,4 +50,4 @@ Cntlで下降できない場合は、以下のコマンドでアクセシビリ ``` # アクセシビリティのCtrl+マウスでカーソル位置表示を無効化 gsettings set org.gnome.desktop.interface locate-pointer false -``` \ No newline at end of file +``` diff --git a/srcs/core/engine.cpp b/srcs/core/engine.cpp index 3c5b9df..65713bd 100644 --- a/srcs/core/engine.cpp +++ b/srcs/core/engine.cpp @@ -27,6 +27,8 @@ #include "renderer/renderer.hpp" #include "renderer/frustum.hpp" #include "world/world.hpp" +#include "world/inventory.hpp" +#include "world/recipe.hpp" #include "player/player.hpp" #include "streaming/chunk_manager.hpp" #include "network/client.hpp" @@ -139,41 +141,6 @@ static void rebuildModified(int wx, int wz, ChunkManager& mgr) { if (lz == CHUNK_SIZE_Z-1) mgr.rebuildChunkAt({cx, cz + 1}); } -// ───────────────────────────────────────────────────────────────────────────── -// インベントリ操作ヘルパー -// ───────────────────────────────────────────────────────────────────────────── - -// ブロックをインベントリに1個追加する。既存スタックに積む → 空きスロットを探す。 -static void inventoryAdd(Inventory& inv, BlockType type) { - if (type == BlockType::Air || type == BlockType::Water || - type == BlockType::ShortGrass || isItem(type)) return; - // Torch はスロット 8 に無限補充されるため、拾っても追加せず捨てる - // (インベントリが松明で埋まるのを防ぐ) - if (type == BlockType::Torch) return; - for (int i = 0; i < HOTBAR_SIZE; ++i) { - if (inv.slots[i].type == type && inv.slots[i].count < STACK_MAX) { - ++inv.slots[i].count; - return; - } - } - for (int i = 0; i < HOTBAR_SIZE; ++i) { - if (inv.slots[i].type == BlockType::Air) { - inv.slots[i] = {type, 1}; - return; - } - } - // インベントリ満杯: 無音で捨てる -} - -// 選択スロットから1個消費する。成功なら true を返す。 -static bool inventoryConsume(Inventory& inv) { - ItemStack& s = inv.slots[inv.selected]; - if (s.type == BlockType::Air || s.count <= 0) return false; - --s.count; - if (s.count == 0) s = {}; - return true; -} - static void applyMobExplosion(const MobExplosion& ex, World& world, ChunkManager& chunk_mgr, NetworkClient* net_client) { @@ -237,6 +204,8 @@ struct Engine::Impl { bool show_minimap_ = true; bool show_player_list_ = false; bool show_stats_ = false; + bool craft_menu_open = false; + int craft_selected = 0; // Multiplayer NetworkClient net_client; @@ -363,11 +332,6 @@ bool Engine::init(uint32_t seed, int width, int height) { // ── チャンクマネージャーの初期化 ─────────────────────────────────────────── impl_->chunk_mgr = new ChunkManager(impl_->world, impl_->renderer); - // 弓を最後のホットバースロットに常備(無限矢扱い) - impl_->inventory.slots[HOTBAR_SIZE - 1] = {BlockType::Bow, 1}; - // 松明をスロット 8(インデックス 7)に常備(無限供給) - impl_->inventory.slots[HOTBAR_SIZE - 2] = {BlockType::Torch, STACK_MAX}; - running_ = true; fprintf(stderr, "[Engine] init OK seed=%u %dx%d\n", seed, width_, height_); return true; @@ -587,110 +551,156 @@ void Engine::run() { { InputHandler& inp = impl_->player.input(); Inventory& inv = impl_->inventory; - - // 松明スロット(インデックス 7 = 数字キー 8)を毎フレーム補充する。 - // 「常に入れておく」要件のため、設置で減っても次フレームで満タンに戻る。 - if (inv.slots[HOTBAR_SIZE - 2].type != BlockType::Torch || - inv.slots[HOTBAR_SIZE - 2].count < STACK_MAX) { - inv.slots[HOTBAR_SIZE - 2] = {BlockType::Torch, STACK_MAX}; + const std::vector& recipes = getRecipes(); + + // C: クラフトメニュー表示切り替え。上下キーで選択、Enter で作成。 + static bool prev_c = false; + static bool prev_up = false; + static bool prev_down = false; + static bool prev_enter = false; + const bool cur_c = inp.isHeld(GLFW_KEY_C); + const bool cur_up = inp.isHeld(GLFW_KEY_UP); + const bool cur_down = inp.isHeld(GLFW_KEY_DOWN); + const bool cur_enter = inp.isHeld(GLFW_KEY_ENTER) || + inp.isHeld(GLFW_KEY_KP_ENTER); + + if (cur_c && !prev_c) { + impl_->craft_menu_open = !impl_->craft_menu_open; + if (!recipes.empty()) { + impl_->craft_selected = std::clamp( + impl_->craft_selected, 0, + static_cast(recipes.size()) - 1); + } + impl_->bow_charging = false; + impl_->bow_charge_time = 0.0f; } - // キー 1〜9: ホットバースロットを選択(ワンショット: 前フレームと差分) - static bool prev_num[9] = {}; - for (int k = 0; k < 9; ++k) { - bool cur = inp.isHeld(GLFW_KEY_1 + k); - if (cur && !prev_num[k]) inv.selected = k; - prev_num[k] = cur; + if (impl_->craft_menu_open) { + if (recipes.empty()) { + impl_->craft_menu_open = false; + } else { + const int recipe_count = static_cast(recipes.size()); + if (cur_up && !prev_up) + impl_->craft_selected = + (impl_->craft_selected + recipe_count - 1) % recipe_count; + if (cur_down && !prev_down) + impl_->craft_selected = + (impl_->craft_selected + 1) % recipe_count; + if (cur_enter && !prev_enter) { + const Recipe& r = recipes[impl_->craft_selected]; + if (applyCraft(inv, r)) + fprintf(stderr, "[Craft] crafted %s\n", r.name); + else + fprintf(stderr, "[Craft] cannot craft %s\n", r.name); + } + } + impl_->bow_charging = false; + impl_->bow_charge_time = 0.0f; } - // スクロールホイールでスロットを循環 - float sw = inp.scrollY(); - if (sw < -0.5f) - inv.selected = (inv.selected + 1) % HOTBAR_SIZE; - else if (sw > 0.5f) - inv.selected = (inv.selected + HOTBAR_SIZE - 1) % HOTBAR_SIZE; - - // カーソルがキャプチャされているときだけブロック操作を受け付ける - if (inp.isCursorCaptured()) { - glm::vec3 pos = impl_->player.camera().position(); - glm::vec3 front = impl_->player.camera().front(); - RayHit hit = castRay(pos, front, 6.0f, impl_->world); - BlockType held = inv.slots[inv.selected].type; - const bool bow_equipped = (held == BlockType::Bow); - - // 左クリック: ブロックを壊してインベントリに追加 OR ゾンビを攻撃 - if (inp.wasLeftClicked()) { - impl_->attack_sync_timer = 0.28f; - if (hit.hit) { - BlockType broken = impl_->world.getWorldBlock( - hit.bx, hit.by, hit.bz); - impl_->world.setWorldBlock(hit.bx, hit.by, hit.bz, - BlockType::Air); - rebuildModified(hit.bx, hit.bz, *impl_->chunk_mgr); - inventoryAdd(inv, broken); - if (impl_->multiplayer) - impl_->net_client.sendBlockChange( - hit.bx, hit.by, hit.bz, - static_cast(BlockType::Air)); - } else { - impl_->mob_mgr.playerMeleeAttack( - pos.x, pos.y, pos.z, front.x, front.z); - } + prev_c = cur_c; + prev_up = cur_up; + prev_down = cur_down; + prev_enter = cur_enter; + + if (!impl_->craft_menu_open) { + // キー 1〜9: ホットバースロットを選択(ワンショット: 前フレームと差分) + static bool prev_num[9] = {}; + for (int k = 0; k < 9; ++k) { + bool cur = inp.isHeld(GLFW_KEY_1 + k); + if (cur && !prev_num[k]) inv.selected = k; + prev_num[k] = cur; } - // 弓装備中: 右クリック長押しでチャージ、離して発射。 - // それ以外: 右クリックでブロックを設置。 - const bool rmb_held = - glfwGetMouseButton(window_, GLFW_MOUSE_BUTTON_RIGHT) == GLFW_PRESS; - - if (bow_equipped) { - if (rmb_held) { - impl_->bow_charging = true; - impl_->bow_charge_time += dt; - if (impl_->bow_charge_time > 1.5f) - impl_->bow_charge_time = 1.5f; - } else if (impl_->bow_charging) { - // 離した瞬間: 矢を発射 - // チャージ 0.1秒以下なら最低威力、1.0秒以上で最大威力 - float t = (impl_->bow_charge_time - 0.1f) / 0.9f; - if (t < 0.0f) t = 0.0f; - if (t > 1.0f) t = 1.0f; - const float speed = 8.0f + 28.0f * t; - // 矢の発射位置: カメラ少し前方・少し下 - const glm::vec3 spawn = pos + front * 0.4f - - glm::vec3(0.0f, 0.15f, 0.0f); - impl_->arrow_mgr.spawn(spawn.x, spawn.y, spawn.z, - front.x * speed, - front.y * speed, - front.z * speed); - impl_->attack_sync_timer = 0.20f; + // スクロールホイールでスロットを循環 + float sw = inp.scrollY(); + if (sw < -0.5f) + inv.selected = (inv.selected + 1) % HOTBAR_SIZE; + else if (sw > 0.5f) + inv.selected = (inv.selected + HOTBAR_SIZE - 1) % HOTBAR_SIZE; + + // カーソルがキャプチャされているときだけブロック操作を受け付ける + if (inp.isCursorCaptured()) { + glm::vec3 pos = impl_->player.camera().position(); + glm::vec3 front = impl_->player.camera().front(); + RayHit hit = castRay(pos, front, 6.0f, impl_->world); + BlockType held = inv.slots[inv.selected].type; + const bool bow_equipped = (held == BlockType::Bow); + + // 左クリック: ブロックを壊してインベントリに追加 OR ゾンビを攻撃 + if (inp.wasLeftClicked()) { + impl_->attack_sync_timer = 0.28f; + if (hit.hit) { + BlockType broken = impl_->world.getWorldBlock( + hit.bx, hit.by, hit.bz); + impl_->world.setWorldBlock(hit.bx, hit.by, hit.bz, + BlockType::Air); + rebuildModified(hit.bx, hit.bz, *impl_->chunk_mgr); + inventoryAdd(inv, broken); + if (impl_->multiplayer) + impl_->net_client.sendBlockChange( + hit.bx, hit.by, hit.bz, + static_cast(BlockType::Air)); + } else { + impl_->mob_mgr.playerMeleeAttack( + pos.x, pos.y, pos.z, front.x, front.z); + } + } + + // 弓装備中: 右クリック長押しでチャージ、離して発射。 + // それ以外: 右クリックでブロックを設置。 + const bool rmb_held = + glfwGetMouseButton(window_, GLFW_MOUSE_BUTTON_RIGHT) == GLFW_PRESS; + + if (bow_equipped) { + if (rmb_held) { + impl_->bow_charging = true; + impl_->bow_charge_time += dt; + if (impl_->bow_charge_time > 1.5f) + impl_->bow_charge_time = 1.5f; + } else if (impl_->bow_charging) { + // 離した瞬間: 矢を発射 + // チャージ 0.1秒以下なら最低威力、1.0秒以上で最大威力 + float t = (impl_->bow_charge_time - 0.1f) / 0.9f; + if (t < 0.0f) t = 0.0f; + if (t > 1.0f) t = 1.0f; + const float speed = 8.0f + 28.0f * t; + // 矢の発射位置: カメラ少し前方・少し下 + const glm::vec3 spawn = pos + front * 0.4f + - glm::vec3(0.0f, 0.15f, 0.0f); + impl_->arrow_mgr.spawn(spawn.x, spawn.y, spawn.z, + front.x * speed, + front.y * speed, + front.z * speed); + impl_->attack_sync_timer = 0.20f; + impl_->bow_charging = false; + impl_->bow_charge_time = 0.0f; + } + } else { + // 弓を持っていないときはチャージ状態をリセット impl_->bow_charging = false; impl_->bow_charge_time = 0.0f; + + // 右クリック: 選択スロットのブロックを設置(在庫があるときのみ) + if (hit.hit && inp.wasRightClicked()) { + BlockType to_place = held; + if (to_place != BlockType::Air && !isItem(to_place) && + inventoryConsumeSelected(inv)) { + impl_->world.setWorldBlock(hit.nx, hit.ny, hit.nz, + to_place); + rebuildModified(hit.nx, hit.nz, *impl_->chunk_mgr); + if (impl_->multiplayer) + impl_->net_client.sendBlockChange( + hit.nx, hit.ny, hit.nz, + static_cast(to_place)); + } + } } } else { - // 弓を持っていないときはチャージ状態をリセット + // カーソル解放中はチャージを中断 impl_->bow_charging = false; impl_->bow_charge_time = 0.0f; - - // 右クリック: 選択スロットのブロックを設置(在庫があるときのみ) - if (hit.hit && inp.wasRightClicked()) { - BlockType to_place = held; - if (to_place != BlockType::Air && !isItem(to_place) && - inventoryConsume(inv)) { - impl_->world.setWorldBlock(hit.nx, hit.ny, hit.nz, - to_place); - rebuildModified(hit.nx, hit.nz, *impl_->chunk_mgr); - if (impl_->multiplayer) - impl_->net_client.sendBlockChange( - hit.nx, hit.ny, hit.nz, - static_cast(to_place)); - } - } } - } else { - // カーソル解放中はチャージを中断 - impl_->bow_charging = false; - impl_->bow_charge_time = 0.0f; } } @@ -991,6 +1001,9 @@ void Engine::run() { // ホットバー(画面下中央)を描画 impl_->renderer.drawHotbar(impl_->inventory); + if (impl_->craft_menu_open) + impl_->renderer.drawCraftMenu( + impl_->inventory, getRecipes(), impl_->craft_selected); // ミニマップ(左上)を更新して描画(M キーでトグル) if (impl_->show_minimap_) { diff --git a/srcs/renderer/renderer.cpp b/srcs/renderer/renderer.cpp index a9b6bfd..89940e7 100644 --- a/srcs/renderer/renderer.cpp +++ b/srcs/renderer/renderer.cpp @@ -15,6 +15,7 @@ #include "renderer/renderer.hpp" #include "types.hpp" #include "world/world.hpp" +#include "world/recipe.hpp" #include "network/client.hpp" #include "mob/zombie.hpp" @@ -1023,8 +1024,7 @@ void Renderer::drawHotbar(const Inventory& inv) { for (int i = 0; i < HOTBAR_SIZE; ++i) { const ItemStack& s = inv.slots[i]; if (s.type == BlockType::Air || s.count <= 0) continue; - if (s.type == BlockType::Bow) continue; - if (s.type == BlockType::Torch) continue; + if (isItem(s.type) || s.type == BlockType::Torch) continue; AtlasUV uv = atlas_.getUV(s.type); float uc = (uv.u0 + uv.u1) * 0.5f; @@ -1086,7 +1086,7 @@ void Renderer::drawHotbar(const Inventory& inv) { } } - // 弓・松明は立方体としてではなく 2D アイコンとして表示する。 + // アイテムと松明は立方体としてではなく 2D アイコンとして表示する。 { hotbar_shader_.setFloat("uBright", 1.0f); float verts[HOTBAR_SIZE * 6 * 4] = {}; @@ -1094,8 +1094,8 @@ void Renderer::drawHotbar(const Inventory& inv) { for (int i = 0; i < HOTBAR_SIZE; ++i) { const ItemStack& s = inv.slots[i]; - if ((s.type != BlockType::Bow && s.type != BlockType::Torch) - || s.count <= 0) continue; + if (!(isItem(s.type) || s.type == BlockType::Torch) || + s.count <= 0) continue; AtlasUV uv = atlas_.getUV(s.type); const float cx = nx(sx0_px + i * (SLOT_PX + GAP_PX) + SLOT_PX * 0.5f); @@ -1146,9 +1146,9 @@ void Renderer::drawHotbar(const Inventory& inv) { appendLine(verts.data(), cnt, x0, sy0, x0, sy1); appendLine(verts.data(), cnt, x1, sy0, x1, sy1); - // 個数(2個以上のとき右下に表示)。Torch は無限供給なので個数は隠す。 + // 個数(2個以上のとき右下に表示)。 const ItemStack& s = inv.slots[i]; - if (s.type != BlockType::Air && s.type != BlockType::Torch && s.count > 1) { + if (s.type != BlockType::Air && s.count > 1) { float dw = 8.0f / hw, dh = 11.0f / hh, dg = 2.0f / hw; appendNumber(verts.data(), cnt, s.count, x1 - 3.0f / hw, sy0 + dh + 3.0f / hh, dw, dh, dg); @@ -1180,6 +1180,211 @@ void Renderer::drawHotbar(const Inventory& inv) { glEnable(GL_DEPTH_TEST); } +// ───────────────────────────────────────────────────────────────────────────── +// drawCraftMenu() — クラフトレシピ一覧をHUDに重ねて描画する +// ───────────────────────────────────────────────────────────────────────────── +void Renderer::drawCraftMenu(const Inventory& inv, + const std::vector& recipes, + int selected_recipe) { + if (recipes.empty()) return; + + const float hw = static_cast(width_) * 0.5f; + const float hh = static_cast(height_) * 0.5f; + + auto nx = [&](float px) { return px / hw - 1.0f; }; + auto ny = [&](float py) { return py / hh - 1.0f; }; + + const int recipe_count = static_cast(recipes.size()); + selected_recipe %= recipe_count; + if (selected_recipe < 0) selected_recipe += recipe_count; + + const float panel_w_px = std::min(500.0f, static_cast(width_) - 40.0f); + const float row_h_px = 58.0f; + const float panel_h_px = 76.0f + row_h_px * static_cast(recipe_count); + const float panel_x_px = (static_cast(width_) - panel_w_px) * 0.5f; + const float panel_y_px = (static_cast(height_) - panel_h_px) * 0.5f + 32.0f; + const float panel_top_px = panel_y_px + panel_h_px; + + auto drawQuad = [&](float qx0, float qy0, float qx1, float qy1, + float r, float g, float b, float a) { + float q[12] = { + qx0, qy0, qx1, qy0, qx1, qy1, + qx0, qy0, qx1, qy1, qx0, qy1 + }; + glBindBuffer(GL_ARRAY_BUFFER, hotbar_vbo_); + glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(q), q); + hud_shader_.setVec4("uColor", r, g, b, a); + glBindVertexArray(hotbar_vao_); + glDrawArrays(GL_TRIANGLES, 0, 6); + }; + + glDisable(GL_DEPTH_TEST); + hud_shader_.use(); + drawQuad(nx(panel_x_px), ny(panel_y_px), + nx(panel_x_px + panel_w_px), ny(panel_top_px), + 0.02f, 0.025f, 0.03f, 0.78f); + drawQuad(nx(panel_x_px), ny(panel_top_px - 5.0f), + nx(panel_x_px + panel_w_px), ny(panel_top_px), + 0.95f, 0.70f, 0.22f, 0.92f); + + for (int i = 0; i < recipe_count; ++i) { + const bool selected = i == selected_recipe; + const bool ready = canCraft(inv, recipes[i]); + const float row_top_px = panel_top_px - 58.0f - row_h_px * static_cast(i); + const float row_bot_px = row_top_px - 48.0f; + const float x0 = nx(panel_x_px + 14.0f); + const float x1 = nx(panel_x_px + panel_w_px - 14.0f); + + if (selected) { + drawQuad(x0, ny(row_bot_px), x1, ny(row_top_px), + ready ? 0.16f : 0.18f, + ready ? 0.20f : 0.12f, + ready ? 0.15f : 0.11f, + 0.82f); + drawQuad(x0, ny(row_top_px - 3.0f), x1, ny(row_top_px), + 0.95f, 0.70f, 0.22f, 0.95f); + } else { + drawQuad(x0, ny(row_bot_px), x1, ny(row_top_px), + ready ? 0.07f : 0.09f, + ready ? 0.10f : 0.075f, + ready ? 0.08f : 0.075f, + 0.62f); + } + } + + glDisable(GL_CULL_FACE); + hotbar_shader_.use(); + hotbar_shader_.setInt("uAtlas", 0); + hotbar_shader_.setFloat("uBright", 1.0f); + atlas_.bind(0); + glBindVertexArray(hotbar_tex_vao_); + + auto drawIcon = [&](BlockType type, float cx_px, float cy_px, float size_px) { + if (type == BlockType::Air) return; + AtlasUV uv = atlas_.getUV(type); + const float cx = nx(cx_px); + const float cy = ny(cy_px); + const float iw = size_px / hw; + const float ih = size_px / hh; + const float x0 = cx - iw * 0.5f; + const float x1 = cx + iw * 0.5f; + const float y0 = cy - ih * 0.5f; + const float y1 = cy + ih * 0.5f; + const float verts[24] = { + x0, y0, uv.u0, uv.v1, + x1, y0, uv.u1, uv.v1, + x1, y1, uv.u1, uv.v0, + x0, y0, uv.u0, uv.v1, + x1, y1, uv.u1, uv.v0, + x0, y1, uv.u0, uv.v0, + }; + glBindBuffer(GL_ARRAY_BUFFER, hotbar_tex_vbo_); + glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(verts), verts); + glDrawArrays(GL_TRIANGLES, 0, 6); + }; + + for (int i = 0; i < recipe_count; ++i) { + const Recipe& r = recipes[i]; + const float row_top_px = panel_top_px - 58.0f - row_h_px * static_cast(i); + const float row_bot_px = row_top_px - 48.0f; + const float cy_px = (row_top_px + row_bot_px) * 0.5f; + drawIcon(r.output.type, panel_x_px + 38.0f, cy_px, 34.0f); + for (size_t j = 0; j < r.inputs.size() && j < 4; ++j) + drawIcon(r.inputs[j].type, + panel_x_px + panel_w_px - 178.0f + 44.0f * static_cast(j), + cy_px, 30.0f); + } + glBindVertexArray(0); + glEnable(GL_CULL_FACE); + + std::array verts{}; + int count = 0; + + auto appendWord = [&](const char* word, float left_px, float top_px, + float w_px, float h_px, float gap_px) { + float x = nx(left_px); + const float y = ny(top_px); + const float w = w_px / hw; + const float h = h_px / hh; + const float gap = gap_px / hw; + for (int i = 0; word[i] != '\0'; ++i) { + if (word[i] == ' ') { + x += w * 0.55f + gap; + continue; + } + appendLetter(verts.data(), count, word[i], x, y, w, h); + x += w + gap; + } + }; + + auto appendWordRight = [&](const char* word, float right_px, float top_px, + float w_px, float h_px, float gap_px) { + int visible = 0; + int spaces = 0; + for (int i = 0; word[i] != '\0'; ++i) { + if (word[i] == ' ') ++spaces; + else ++visible; + } + const float total = static_cast(visible) * w_px + + static_cast(visible + spaces - 1) * gap_px + + static_cast(spaces) * w_px * 0.55f; + appendWord(word, right_px - total, top_px, w_px, h_px, gap_px); + }; + + auto appendCount = [&](int value, float right_px, float top_px) { + appendNumber(verts.data(), count, value, nx(right_px), ny(top_px), + 7.0f / hw, 10.0f / hh, 2.0f / hw); + }; + + appendWord("CRAFT", panel_x_px + 20.0f, panel_top_px - 18.0f, + 13.0f, 19.0f, 5.0f); + appendWordRight("MATS", panel_x_px + panel_w_px - 36.0f, + panel_top_px - 18.0f, 10.0f, 15.0f, 4.0f); + + for (int i = 0; i < recipe_count; ++i) { + const Recipe& r = recipes[i]; + const bool ready = canCraft(inv, r); + const bool selected = i == selected_recipe; + const float row_top_px = panel_top_px - 58.0f - row_h_px * static_cast(i); + const float row_bot_px = row_top_px - 48.0f; + const float cy_px = (row_top_px + row_bot_px) * 0.5f; + + appendWord(r.name, panel_x_px + 68.0f, row_top_px - 14.0f, + 11.0f, 16.0f, 4.0f); + appendWordRight(ready ? "CAN" : "NEED", + panel_x_px + panel_w_px - 18.0f, + row_top_px - 16.0f, 9.0f, 13.0f, 3.0f); + + if (r.output.count > 1) + appendCount(r.output.count, panel_x_px + 58.0f, cy_px - 8.0f); + for (size_t j = 0; j < r.inputs.size() && j < 4; ++j) { + const float x = panel_x_px + panel_w_px - 160.0f + + 44.0f * static_cast(j); + appendCount(r.inputs[j].count, x, cy_px - 8.0f); + } + + if (selected) { + const float x0 = nx(panel_x_px + 14.0f); + const float x1 = nx(panel_x_px + panel_w_px - 14.0f); + const float y0 = ny(row_bot_px); + const float y1 = ny(row_top_px); + appendLine(verts.data(), count, x0, y0, x1, y0); + appendLine(verts.data(), count, x0, y1, x1, y1); + appendLine(verts.data(), count, x0, y0, x0, y1); + appendLine(verts.data(), count, x1, y0, x1, y1); + } + } + + glBindBuffer(GL_ARRAY_BUFFER, hud_vbo_); + glBufferSubData(GL_ARRAY_BUFFER, 0, count * sizeof(float), verts.data()); + hud_shader_.use(); + hud_shader_.setVec4("uColor", 0.95f, 0.92f, 0.80f, 0.96f); + glBindVertexArray(hud_vao_); + glDrawArrays(GL_LINES, 0, count / 2); + glBindVertexArray(0); + glEnable(GL_DEPTH_TEST); +} + // ───────────────────────────────────────────────────────────────────────────── // drawUnderwaterOverlay() — 水中にいるとき画面を青くするオーバーレイ // diff --git a/srcs/renderer/renderer.hpp b/srcs/renderer/renderer.hpp index 3c0492c..f3cbc49 100644 --- a/srcs/renderer/renderer.hpp +++ b/srcs/renderer/renderer.hpp @@ -17,6 +17,7 @@ struct GLFWwindow; class World; struct RemotePlayer; +struct Recipe; class Renderer : public IRenderer { public: @@ -41,6 +42,8 @@ class Renderer : public IRenderer { bool multiplayer); void drawDeathScreen(); void drawHotbar(const Inventory& inv); + void drawCraftMenu(const Inventory& inv, const std::vector& recipes, + int selected_recipe); void drawUnderwaterOverlay(); void drawFirstPersonHand(float walk_phase, float attack_timer_norm, bool bow_equipped = false, float bow_charge = 0.0f); diff --git a/srcs/world/inventory.cpp b/srcs/world/inventory.cpp index 8cbc0e5..169e54d 100644 --- a/srcs/world/inventory.cpp +++ b/srcs/world/inventory.cpp @@ -3,6 +3,10 @@ // ───────────────────────────────────────────────────────────────────────────── #include "inventory.hpp" +static int stackLimit(BlockType type) { + return type == BlockType::Bow ? 1 : STACK_MAX; +} + void inventoryAdd(Inventory& inv, BlockType type, int n) { if (n <= 0) return; if (type == BlockType::Air || type == BlockType::Water || @@ -10,10 +14,12 @@ void inventoryAdd(Inventory& inv, BlockType type, int n) { type == BlockType::Mushroom) return; + const int limit = stackLimit(type); + // 既存スタックに積む for (int i = 0; i < HOTBAR_SIZE && n > 0; ++i) { - if (inv.slots[i].type == type && inv.slots[i].count < STACK_MAX) { - int add = STACK_MAX - inv.slots[i].count; + if (inv.slots[i].type == type && inv.slots[i].count < limit) { + int add = limit - inv.slots[i].count; if (add > n) add = n; inv.slots[i].count += add; n -= add; @@ -22,13 +28,12 @@ void inventoryAdd(Inventory& inv, BlockType type, int n) { // 空きスロットを埋める for (int i = 0; i < HOTBAR_SIZE && n > 0; ++i) { if (inv.slots[i].type == BlockType::Air) { - int add = n > STACK_MAX ? STACK_MAX : n; + int add = n > limit ? limit : n; inv.slots[i] = {type, add}; n -= add; } } - // インベントリ満杯: 残りは捨てる (Bow 等の上限1は STACK_MAX 側で表現される想定だが - // 現状 STACK_MAX=64 のため Bow も技術上は 64 まで積める。仕様上は気にしない) + // インベントリ満杯: 残りは捨てる } bool inventoryConsumeSelected(Inventory& inv) { diff --git a/srcs/world/recipe.cpp b/srcs/world/recipe.cpp index 9d359cf..1aa74aa 100644 --- a/srcs/world/recipe.cpp +++ b/srcs/world/recipe.cpp @@ -16,21 +16,60 @@ const std::vector& getRecipes() { { {BlockType::Stick, 1}, {BlockType::Wood, 1} }, { BlockType::Torch, 4 }, }, + { + "BOW", + { {BlockType::Stick, 3}, {BlockType::Wood, 2} }, + { BlockType::Bow, 1 }, + }, }; return kRecipes; } -bool canCraft(const Inventory& inv, const Recipe& r) { +static int stackLimit(BlockType type) { + return type == BlockType::Bow ? 1 : STACK_MAX; +} + +static bool canFitOutput(const Inventory& inv, const ItemStack& out) { + if (out.type == BlockType::Air || out.count <= 0) return false; + + int remaining = out.count; + const int limit = stackLimit(out.type); + for (int i = 0; i < HOTBAR_SIZE && remaining > 0; ++i) { + const ItemStack& s = inv.slots[i]; + if (s.type == out.type && s.count < limit) { + int room = limit - s.count; + remaining -= room; + } + } + for (int i = 0; i < HOTBAR_SIZE && remaining > 0; ++i) { + if (inv.slots[i].type == BlockType::Air) + remaining -= limit; + } + return remaining <= 0; +} + +static bool hasIngredients(const Inventory& inv, const Recipe& r) { for (const Ingredient& in : r.inputs) if (inventoryCount(inv, in.type) < in.count) return false; return true; } +bool canCraft(const Inventory& inv, const Recipe& r) { + if (!hasIngredients(inv, r)) return false; + Inventory next = inv; + for (const Ingredient& in : r.inputs) + inventoryConsume(next, in.type, in.count); + return canFitOutput(next, r.output); +} + bool applyCraft(Inventory& inv, const Recipe& r) { - if (!canCraft(inv, r)) return false; + if (!hasIngredients(inv, r)) return false; + Inventory next = inv; for (const Ingredient& in : r.inputs) - inventoryConsume(inv, in.type, in.count); - inventoryAdd(inv, r.output.type, r.output.count); + inventoryConsume(next, in.type, in.count); + if (!canFitOutput(next, r.output)) return false; + inventoryAdd(next, r.output.type, r.output.count); + inv = next; return true; } diff --git a/srcs/world/recipe.hpp b/srcs/world/recipe.hpp index f5180ca..a3c8554 100644 --- a/srcs/world/recipe.hpp +++ b/srcs/world/recipe.hpp @@ -23,7 +23,7 @@ struct Recipe { // クラフトメニューに表示する全レシピ。表示順 = 配列順。 const std::vector& getRecipes(); -// inv の所持数がレシピを満たすか判定。 +// inv が素材と出力先の空きを満たし、レシピを実行できるか判定。 bool canCraft(const Inventory& inv, const Recipe& r); // 素材を消費して出力をインベントリに追加する。canCraft が true のときに呼ぶ。