diff --git a/docs/AVA2-FEATURE-MIGRATION-PLAN.md b/docs/AVA2-FEATURE-MIGRATION-PLAN.md index d14e4d3d..05b8c404 100644 --- a/docs/AVA2-FEATURE-MIGRATION-PLAN.md +++ b/docs/AVA2-FEATURE-MIGRATION-PLAN.md @@ -115,7 +115,7 @@ 2026-10-15 后续批次:阶段 0 的只读能力协商、阶段 2/3 的掉落/来源/宝箱查询、阶段 4 属性面板已实现并在隔离环境测试;未部署 203,待联机与真机验收。范围和缺口见 AVA2-READONLY-FEATURES.md。 -2026-10-15 再续:龙穴/周任务信息与提醒、共享真实掉落开关的活动日历已实现并在隔离环境验证。副本仅首批龙穴;活动仅月光宝箱/圣诞袜,生产未部署,无生产排期。自动洗练/锁定、战令、模型预览、批量开箱仍待实现。见 AVA2-DUNGEON-CALENDAR.md。 +2026-10-15 再续:龙穴/周任务信息与提醒、共享真实掉落开关的活动日历已实现并在隔离环境验证。副本仅首批龙穴;活动仅月光宝箱/圣诞袜,生产未部署,无生产排期。自动洗练/锁定、战令、模型预览、批量开箱仍待实现(2026-10-16:模型预览已实现,见 FEATURE-MODEL-PREVIEW.md)。见 AVA2-DUNGEON-CALENDAR.md。 2026-10-15 自动洗练 v1(阶段 6,不含属性锁定):服务端 mt_switchbot 逐次调用原 UseItem 洗练路径,客户端 mt_switchbot 窗口;隔离环境编译与桩测试通过,macOS 客户端已在 203 测试核心联机验收;未部署 203 正式环境、未做 Android 真机测试。属性锁定仍待存储设计。见 AVA2-SWITCHBOT.md。 diff --git a/docs/FEATURE-MODEL-PREVIEW.md b/docs/FEATURE-MODEL-PREVIEW.md new file mode 100644 index 00000000..e0ef9c30 --- /dev/null +++ b/docs/FEATURE-MODEL-PREVIEW.md @@ -0,0 +1,81 @@ +# Model preview + +Ported from AVA2's render-target window (`EterLib/CRenderTarget*`, `GrpRenderTargetTexture.*`, `UserInterface/CPythonRenderTargetModule.cpp`, `root/uirendertarget.py`). The preview shows: +- a wearable item on a character of every race that may wear it; +- a monster, with its motions. + +It is client only; the server is not involved. + +## Entry points + +| Where | How | +|---|---| +| Inventory | hold X and left-click the item | +| NPC shop | hold X and left-click the item | +| Wiki detail | the "模型预览" line, on weapon / armor / costume rows that have a model, and on every monster | + +Without X the original click path runs unchanged. The inventory wrapper chains onto `mt_mobile_inventory`'s, because `mt_preview` is installed after it. There is no touch entry other than the wiki. + +## Window (`ScriptLib/ModelPreviewScript.inc`, module `mt_preview`) + +### Item mode +- `ItemSpec(vnum)` accepts: + - a weapon other than an arrow → `SetWeapon`; + - a body armor or a body costume → `SetArmor`; + - a hair costume → `SetHair`, which reads value3. +- The races are those not excluded by `ITEM_ANTIFLAG_{MALE,FEMALE,WARRIOR,ASSASSIN,SURA,SHAMAN}`, ordered warrior, assassin, sura, shaman, with male first. The window starts on the player's own race if it is allowed. +- `<` / `>` step through the allowed races. + +### Mob mode +- `SelectModel(vnum)`. +- Motion buttons: 待机 1, 攻击 13, 受击 5, 死亡 11, 技能1–4 33–36 (`InterceptOnceMotion`). + +### Controls +- Dragging with the left button turns the camera: absolute yaw/pitch from the press point, pitch clamped to ±60°. +- 放大 / 缩小 change the zoom in 5 % steps, clamped to −8..+12. Windows have no wheel event. +- 自动旋转 spins the model. +- 复位 rebuilds the model, which also resets the camera. + +### Lifetime +- Render target index 1 is created at 300×330 when the window opens. +- It is removed on close or Esc, and on every re-open before it is created again. `GameWindow.Close` also calls `renderTarget.Destroy()`. +- The host frees the GPU image 120 frames after its last use. + +## Native side + +- `renderTarget` Python module (`port/UserInterface/PythonRenderTargetModule.cpp`) → `CRenderTargetManager` → `CRenderTarget`, which owns a `CInstanceBase` and a `CGraphicRenderTargetTexture`. +- `CPythonApplication` calls `DeformModels` / `UpdateModels` / `RenderModels` around the character manager. + +### Port differences from AVA2 +- Player races (0..7) are created as `TYPE_PC`. 40250 reserves the armor/weapon/hair parts only for PCs, so AVA2's `TYPE_NPC` crashed on the first `ChangeArmor`. +- The model sits at the world origin, where culling hides it. Instead of AVA2's `SetAlwaysRender`, it is shown again before `Deform` and `Render`. Its text tail is detached. +- The background quad is drawn inside the render target, in `RenderModel`. `RenderBackgrounds` is a no-op. +- Wiki affects are not ported. +- The texture has 1 mip level. + +### Host (Vulkan) +- A colour render target is `D3DFMT_X8R8G8B8` / `A8R8G8B8` with `D3DUSAGE_RENDERTARGET`, named `rt::x:c`. +- It gets its own RGBA8 offscreen pass (pipeline key bits 29–30: 0 none, 1 shadow, 2 colour) and is cleared to black. +- Shadow targets keep the previous path. +- Side effect: SnowEnvironment's blur targets now take the colour path. Snow blur is off by default. + +## Verification + +- `tests/wiki/test_model_preview.py` covers: + - `ItemSpec` races and slots; + - race stepping and fallback; + - motions and zoom clamps; + - drag; + - reset; + - the view drawing the target; + - no live target after close, Esc, or 5 re-opens; + - the inventory and shop wrappers; + - wiki links and module order. +- ctest 49/49. +- Live, channel5 13040, macOS client, 2026-10-16: + - armor 11299 on a warrior; + - sword 19 after a 90°/20° turn; + - wild dog 101 with the attack motion; + - boss 691 zoomed in with spin. + - The main scene was unchanged. + - 120 open/close cycles (30 paced, plus 60 in one frame) did not grow RSS: 668 MB before, 593 MB after. diff --git a/src/host/vk_frame.cpp b/src/host/vk_frame.cpp index 57e8e64e..520fc8ee 100644 --- a/src/host/vk_frame.cpp +++ b/src/host/vk_frame.cpp @@ -173,7 +173,7 @@ void VulkanWindow::prepare_draws(const std::vector& draws, const T RenderTarget* target = nullptr; if (draw.render_target) { target = get_render_target( - Render3DRenderTargetName(draw.render_target, draw.target_width, draw.target_height)); + Render3DRenderTargetName(draw.render_target, draw.target_width, draw.target_height, draw.target_color)); if (!target) continue; } if (draw.clear_flags && target) { @@ -257,7 +257,8 @@ void VulkanWindow::prepare_draws(const std::vector& draws, const T } } const auto pipeline = get_pipeline(cull, depth, blend, draw.lines, - static_cast(draw.z_func), target != nullptr, &draw); + static_cast(draw.z_func), + std::uint8_t(target ? (target->color ? 2 : 1) : 0), &draw); // D3DTOP_DISABLE (1) on stage 0 or 1 ends the cascade before stage 1 samples. const bool bind_tex1 = !draw.texture1.empty() && draw.color_op[0] > 1 && draw.color_op[1] > 1; const auto descriptor = get_texture_descriptor( @@ -265,8 +266,9 @@ void VulkanWindow::prepare_draws(const std::vector& draws, const T auto constants = make_push_constants(draw, skin_offset_encoded); if (draw.pretransformed) { - const float screen_width = float(logical_width()); - const float screen_height = float(logical_height()); + // PORT: a pretransformed draw into a render target is in the target's pixels. + const float screen_width = target ? float(target->width) : float(logical_width()); + const float screen_height = target ? float(target->height) : float(logical_height()); // Screen pixels (y down) to D3D NDC (y up); the shader's Y flip then maps to Vulkan. constants.mvp = {2.0f / screen_width, 0, 0, 0, 0, -2.0f / screen_height, 0, 0, @@ -448,7 +450,7 @@ void VulkanWindow::record_passes(std::uint32_t image_index, const FrameDraws& fr for (std::size_t i = 0; i < frame.offscreen_draws.size();) { RenderTarget* target = frame.offscreen_draws[i].target; VkRenderPassBeginInfo offscreen_begin{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO}; - offscreen_begin.renderPass = offscreen_pass_; + offscreen_begin.renderPass = target->color ? offscreen_color_pass_ : offscreen_pass_; offscreen_begin.framebuffer = target->framebuffer; offscreen_begin.renderArea.extent = {target->width, target->height}; vkCmdBeginRenderPass(f_->command, &offscreen_begin, VK_SUBPASS_CONTENTS_INLINE); @@ -559,10 +561,10 @@ VulkanWindow::Readback VulkanWindow::record_readbacks(std::uint32_t image_index) const char* dump_rt = std::getenv("MT_DUMP_RT"); if (readback.bmp && dump_rt && *dump_rt) { readback.dump_rt = dump_rt; - const std::uint32_t texel = offscreen_format_ == VK_FORMAT_R5G6B5_UNORM_PACK16 ? 2 : 4; for (auto& entry : render_targets_) { RenderTarget& rt = entry.second; - Readback::RtDump dump{VK_NULL_HANDLE, VK_NULL_HANDLE, rt.width, rt.height}; + const std::uint32_t texel = !rt.color && offscreen_format_ == VK_FORMAT_R5G6B5_UNORM_PACK16 ? 2 : 4; + Readback::RtDump dump{VK_NULL_HANDLE, VK_NULL_HANDLE, rt.width, rt.height, texel}; VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; info.size = VkDeviceSize(rt.width) * rt.height * texel; info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; check(vkCreateBuffer(device_, &info, nullptr, &dump.buffer), "vkCreateBuffer rt dump"); @@ -615,9 +617,9 @@ void VulkanWindow::finish_readbacks(Readback& readback) { for (auto& sink : readback.sinks) sink(bgra.data(), w, h); } vkUnmapMemory(device_, readback.memory); - const std::uint32_t texel = offscreen_format_ == VK_FORMAT_R5G6B5_UNORM_PACK16 ? 2 : 4; for (std::size_t n = 0; n < readback.rt_dumps.size(); ++n) { const auto& dump = readback.rt_dumps[n]; + const std::uint32_t texel = dump.texel; void* data = nullptr; check(vkMapMemory(device_, dump.memory, 0, VK_WHOLE_SIZE, 0, &data), "vkMapMemory rt dump"); const auto* bytes = static_cast(data); diff --git a/src/host/vk_pipelines.cpp b/src/host/vk_pipelines.cpp index 926224f7..723a6b44 100644 --- a/src/host/vk_pipelines.cpp +++ b/src/host/vk_pipelines.cpp @@ -104,8 +104,13 @@ void VulkanWindow::create_offscreen_pass() { VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT; offscreen_format_ = (properties.optimalTilingFeatures & wanted) == wanted ? VK_FORMAT_R5G6B5_UNORM_PACK16 : VK_FORMAT_R8G8B8A8_UNORM; + offscreen_pass_ = make_offscreen_pass(offscreen_format_); + offscreen_color_pass_ = make_offscreen_pass(VK_FORMAT_R8G8B8A8_UNORM); +} + +VkRenderPass VulkanWindow::make_offscreen_pass(VkFormat format) { VkAttachmentDescription attachments[2]{}; - attachments[0].format = offscreen_format_; attachments[0].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[0].format = format; attachments[0].samples = VK_SAMPLE_COUNT_1_BIT; attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_LOAD; attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_STORE; attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; @@ -146,15 +151,17 @@ void VulkanWindow::create_offscreen_pass() { pass_info.attachmentCount = 2; pass_info.pAttachments = attachments; pass_info.subpassCount = 1; pass_info.pSubpasses = &subpass; pass_info.dependencyCount = 2; pass_info.pDependencies = dependencies; - check(vkCreateRenderPass(device_, &pass_info, nullptr, &offscreen_pass_), "vkCreateRenderPass offscreen"); + VkRenderPass pass = VK_NULL_HANDLE; + check(vkCreateRenderPass(device_, &pass_info, nullptr, &pass), "vkCreateRenderPass offscreen"); + return pass; } VkPipeline VulkanWindow::get_pipeline(std::uint8_t cull, std::uint8_t depth, std::uint8_t blend, - bool lines, std::uint8_t z_func, bool offscreen, const Render3DDraw* draw) { + bool lines, std::uint8_t z_func, std::uint8_t offscreen, const Render3DDraw* draw) { std::array key{}; key[0] = std::uint32_t(cull) | (std::uint32_t(depth) << 8) | (std::uint32_t(blend) << 16) | (lines ? (1u << 24) : 0u) | - (std::uint32_t(z_func & 0xfu) << 25) | (offscreen ? (1u << 29) : 0u); + (std::uint32_t(z_func & 0xfu) << 25) | (std::uint32_t(offscreen & 3u) << 29); static const bool specialize = std::getenv("MT_GENERIC_SHADERS") == nullptr; key[1] = specialize ? 1u : 0u; if (specialize && draw) { @@ -296,7 +303,7 @@ VkPipeline VulkanWindow::get_pipeline(std::uint8_t cull, std::uint8_t depth, std pipeline_info.pMultisampleState = &multisample; pipeline_info.pDepthStencilState = &depth_stencil; pipeline_info.pColorBlendState = &blending; pipeline_info.pDynamicState = &dynamic_info; - pipeline_info.layout = layout_; pipeline_info.renderPass = offscreen ? offscreen_pass_ : pass_; + pipeline_info.layout = layout_; pipeline_info.renderPass = offscreen == 2 ? offscreen_color_pass_ : offscreen ? offscreen_pass_ : pass_; VkPipeline handle = VK_NULL_HANDLE; check(vkCreateGraphicsPipelines(device_, VK_NULL_HANDLE, 1, &pipeline_info, nullptr, &handle), "vkCreateGraphicsPipelines"); pipelines_.emplace(key, handle); diff --git a/src/host/vk_resources.cpp b/src/host/vk_resources.cpp index 416109c8..ef0fa387 100644 --- a/src/host/vk_resources.cpp +++ b/src/host/vk_resources.cpp @@ -270,15 +270,23 @@ void VulkanWindow::ensure_ui_capacity(std::size_t command_count) { } VulkanWindow::RenderTarget* VulkanWindow::get_render_target(const std::string& name) { - if (auto it = render_targets_.find(name); it != render_targets_.end()) return &it->second; + if (auto it = render_targets_.find(name); it != render_targets_.end()) { + it->second.last_used_frame = frame_number_; + return &it->second; + } unsigned id = 0, w = 0, h = 0; - if (std::sscanf(name.c_str(), "rt:%u:%ux%u", &id, &w, &h) != 3 || !w || !h || w > 4096 || h > 4096) + int end = 0; + if (std::sscanf(name.c_str(), "rt:%u:%ux%u%n", &id, &w, &h, &end) != 3 || !w || !h || w > 4096 || h > 4096) return nullptr; + const bool color = name.compare(std::size_t(end), std::string::npos, ":c") == 0; + if (!color && name[std::size_t(end)] != '\0') return nullptr; RenderTarget& target = render_targets_[name]; target.width = w; target.height = h; + target.color = color; + target.last_used_frame = frame_number_; auto make_image = [&](VkFormat format, VkImageUsageFlags usage, VkImageAspectFlags aspect, - VkImage& image, VkDeviceMemory& memory, VkImageView& view) { + VkImage& image, VkDeviceMemory& memory, VkImageView& view, bool opaque = false) { VkImageCreateInfo info{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO}; info.imageType = VK_IMAGE_TYPE_2D; info.format = format; @@ -299,18 +307,20 @@ VulkanWindow::RenderTarget* VulkanWindow::get_render_target(const std::string& n VkImageViewCreateInfo view_info{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; view_info.image = image; view_info.viewType = VK_IMAGE_VIEW_TYPE_2D; view_info.format = format; view_info.subresourceRange = {aspect, 0, 1, 0, 1}; + // A colour target is D3DFMT_X8R8G8B8: sampled alpha is always 1. + if (opaque) view_info.components.a = VK_COMPONENT_SWIZZLE_ONE; check(vkCreateImageView(device_, &view_info, nullptr, &view), "vkCreateImageView render target"); }; - make_image(offscreen_format_, + make_image(color ? VK_FORMAT_R8G8B8A8_UNORM : offscreen_format_, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT, - VK_IMAGE_ASPECT_COLOR_BIT, target.texture.image, target.texture.memory, target.texture.view); + VK_IMAGE_ASPECT_COLOR_BIT, target.texture.image, target.texture.memory, target.texture.view, color); make_image(VK_FORMAT_D32_SFLOAT, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, VK_IMAGE_ASPECT_DEPTH_BIT, target.depth_image, target.depth_memory, target.depth_view); const VkImageView views[2] = {target.texture.view, target.depth_view}; VkFramebufferCreateInfo fb{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO}; - fb.renderPass = offscreen_pass_; + fb.renderPass = color ? offscreen_color_pass_ : offscreen_pass_; fb.attachmentCount = 2; fb.pAttachments = views; fb.width = w; fb.height = h; fb.layers = 1; check(vkCreateFramebuffer(device_, &fb, nullptr, &target.framebuffer), "vkCreateFramebuffer render target"); @@ -343,7 +353,8 @@ void VulkanWindow::initialize_render_target(RenderTarget& target) { barriers[1].subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; vkCmdPipelineBarrier(f_->command, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 2, barriers); - const VkClearColorValue white{{1.0f, 1.0f, 1.0f, 1.0f}}; + const float shade = target.color ? 0.0f : 1.0f; + const VkClearColorValue white{{shade, shade, shade, 1.0f}}; const VkClearDepthStencilValue far{1.0f, 0}; vkCmdClearColorImage(f_->command, target.texture.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &white, 1, &barriers[0].subresourceRange); diff --git a/src/host/vk_scene.cpp b/src/host/vk_scene.cpp index d88e0adb..c143d73c 100644 --- a/src/host/vk_scene.cpp +++ b/src/host/vk_scene.cpp @@ -181,7 +181,7 @@ VulkanWindow::PreparedDraw VulkanWindow::scene_composite_draw() { for (int i = 0; i < 16; ++i) m[i] = i % 5 == 0 ? 1.0f : 0.0f; PreparedDraw composite{}; composite.geometry = &scene_quad_; - composite.pipeline = get_pipeline(0, 0, 0, false, 4, false, &quad); + composite.pipeline = get_pipeline(0, 0, 0, false, 4, 0, &quad); composite.descriptor = scene_texture_.descriptor; composite.constants = make_push_constants(quad, 0.0f); // Unit square (y down) to D3D NDC (y up), as the pretransformed draws map screen pixels. diff --git a/src/host/vk_textures.cpp b/src/host/vk_textures.cpp index 59cec68f..32a7e2a9 100644 --- a/src/host/vk_textures.cpp +++ b/src/host/vk_textures.cpp @@ -430,6 +430,19 @@ void VulkanWindow::prune_textures() { it = textures_.erase(it); } else ++it; } + // PORT: render targets too (a closed model preview); a later use recreates and clears one. + for (auto it = render_targets_.begin(); it != render_targets_.end();) { + if (frame_number_ - it->second.last_used_frame > static_retention_frames) { + for (auto pair = paired_descriptors_.begin(); pair != paired_descriptors_.end();) { + if (pair->first.find("\n" + it->first + "\n") != std::string::npos) { + vkFreeDescriptorSets(device_, descriptor_pool_, 1, &pair->second.set); + pair = paired_descriptors_.erase(pair); + } else ++pair; + } + release_render_target(it->second); + it = render_targets_.erase(it); + } else ++it; + } } } // namespace mt_host diff --git a/src/host/vulkan_window.cpp b/src/host/vulkan_window.cpp index 6fa18cbb..39251afa 100644 --- a/src/host/vulkan_window.cpp +++ b/src/host/vulkan_window.cpp @@ -125,6 +125,7 @@ VulkanWindow::~VulkanWindow() { for (auto& entry : textures_) release_texture(entry.second); for (auto& entry : render_targets_) release_render_target(entry.second); if (offscreen_pass_) vkDestroyRenderPass(device_, offscreen_pass_, nullptr); + if (offscreen_color_pass_) vkDestroyRenderPass(device_, offscreen_color_pass_, nullptr); destroy_scene_target(); release_geometry(scene_quad_); if (scene_pass_) vkDestroyRenderPass(device_, scene_pass_, nullptr); diff --git a/src/host/vulkan_window.h b/src/host/vulkan_window.h index 38e788f2..951f552b 100644 --- a/src/host/vulkan_window.h +++ b/src/host/vulkan_window.h @@ -56,8 +56,9 @@ class VulkanWindow { VkDescriptorSet set = VK_NULL_HANDLE; std::uint64_t last_used_frame = 0; }; - // An offscreen D3D render-target texture (the 40250 character shadow map, "rt::x"): - // drawn by the frame's offscreen pass ahead of the back-buffer pass and sampled by that pass. + // An offscreen D3D render-target texture (the 40250 character shadow map, "rt::x", or a + // colour target such as the model preview, "rt::x:c"): drawn by the frame's offscreen pass + // ahead of the back-buffer pass and sampled by that pass. // Between frames the colour image stays SHADER_READ_ONLY_OPTIMAL and the depth image // DEPTH_STENCIL_ATTACHMENT_OPTIMAL; the render pass keeps (LOAD/STORE) both, like a D3D surface. struct RenderTarget { @@ -67,7 +68,9 @@ class VulkanWindow { VkImageView depth_view = VK_NULL_HANDLE; VkFramebuffer framebuffer = VK_NULL_HANDLE; std::uint32_t width = 0, height = 0; - bool initialized = false; // cleared to white / depth 1 by the first frame that uses it + bool initialized = false; // cleared (shadow white, colour black) / depth 1 by the first frame that uses it + bool color = false; // RGBA8 drawn with offscreen_color_pass_; otherwise offscreen_format_ + std::uint64_t last_used_frame = 0; // pruned with the textures once unused }; struct PreparedDraw { Geometry* geometry = nullptr; @@ -223,7 +226,7 @@ private: }; // Host copies of the swapchain image (--screenshot-out) and, with MT_DUMP_RT, of every render target. struct Readback { - struct RtDump { VkBuffer buffer; VkDeviceMemory memory; std::uint32_t w, h; }; + struct RtDump { VkBuffer buffer; VkDeviceMemory memory; std::uint32_t w, h, texel; }; bool screenshot = false; // the swapchain image is copied (a BMP or a frame sink wants it) bool bmp = false; // --screenshot-out std::vector sinks; @@ -297,7 +300,9 @@ private: // The render pass of offscreen render-target textures: colour (R5G6B5 like the D3D surface when // the device can render to it) + D32 depth, both loaded and stored, colour left shader-readable. + // PORT: plus offscreen_color_pass_, the same pass over RGBA8 for colour targets. void create_offscreen_pass(); + VkRenderPass make_offscreen_pass(VkFormat format); // The reduced-resolution scene pass: pass_'s attachments (so pass_ pipelines draw in it) at // scene_extent_, resolving into the sampled scene_texture_. Unsupported when the swapchain @@ -322,9 +327,10 @@ private: // the layouts offscreen_pass_ expects. Recorded outside any render pass. void initialize_render_target(RenderTarget& target); - // offscreen: for offscreen_pass_ (single-sampled render-target texture) instead of pass_. + // offscreen: 1 for offscreen_pass_, 2 for offscreen_color_pass_ (single-sampled render-target + // texture) instead of pass_. VkPipeline get_pipeline(std::uint8_t cull, std::uint8_t depth, std::uint8_t blend, - bool lines = false, std::uint8_t z_func = 4, bool offscreen = false, + bool lines = false, std::uint8_t z_func = 4, std::uint8_t offscreen = 0, const Render3DDraw* draw = nullptr); VkDescriptorSet allocate_texture_descriptor_set(VkImageView view0, VkImageView view1, @@ -478,6 +484,7 @@ private: std::unordered_map paired_descriptors_; std::unordered_map render_targets_; VkRenderPass offscreen_pass_ = VK_NULL_HANDLE; + VkRenderPass offscreen_color_pass_ = VK_NULL_HANDLE; float render_scale_ = 1.0f; bool scene_supported_ = false; VkRenderPass scene_pass_ = VK_NULL_HANDLE; diff --git a/src/platform/EterLib/RecordingDevice.cpp b/src/platform/EterLib/RecordingDevice.cpp index 2f4d91e3..21b63a05 100644 --- a/src/platform/EterLib/RecordingDevice.cpp +++ b/src/platform/EterLib/RecordingDevice.cpp @@ -192,6 +192,13 @@ HRESULT CpuSurface::LockRect(D3DLOCKED_RECT* locked, const RECT* rect, DWORD fla return S_OK; } +// PORT: a 32-bit render-target texture (the AVA2 model preview's CGraphicRenderTargetTexture), drawn on the +// GPU with full state; the R5G6B5 ones are the character shadow map. +bool is_color_target(const D3DSURFACE_DESC& desc) +{ + return (desc.Usage & D3DUSAGE_RENDERTARGET) && (desc.Format == D3DFMT_X8R8G8B8 || desc.Format == D3DFMT_A8R8G8B8); +} + D3DSURFACE_DESC surface_desc(UINT width, UINT height, D3DFORMAT format, DWORD usage, D3DPOOL pool) { D3DSURFACE_DESC desc = {}; @@ -454,7 +461,10 @@ public: return S_OK; } auto* surface = static_cast(m_renderTarget); - if (!surface->parent || surface->level != 0 || surface->desc.Format != D3DFMT_R5G6B5) + const bool colorTarget = surface->parent && surface->level == 0 && is_color_target(surface->desc); + if (colorTarget && !g_gpu_render_targets) + return S_OK; + if (!colorTarget && (!surface->parent || surface->level != 0 || surface->desc.Format != D3DFMT_R5G6B5)) return S_OK; if (g_gpu_render_targets) { @@ -463,6 +473,7 @@ public: clear.render_target = surface->parent->id; clear.target_width = surface->desc.Width; clear.target_height = surface->desc.Height; + clear.target_color = colorTarget; clear.clear_flags = flags & (D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER); clear.clear_color = color; clear.clear_z = depth; @@ -1140,11 +1151,30 @@ private: if (m_renderTarget == m_backBuffer) Render3DAdd(std::move(draw)); else if (g_gpu_render_targets) - record_shadow(std::move(draw)); + { + auto* surface = static_cast(m_renderTarget); + if (surface->parent && surface->level == 0 && is_color_target(surface->desc)) + record_color(std::move(draw), *surface); + else + record_shadow(std::move(draw)); + } else rasterize_shadow(draw); } + // PORT: a draw into a colour render target keeps every state the back buffer would get. + void record_color(Render3DDraw draw, const CpuSurface& surface) + { + if (!draw.shared_geometry && draw.indices.size() < (draw.lines ? 2u : 3u)) + return; + draw.render_target = surface.parent->id; + draw.target_width = surface.desc.Width; + draw.target_height = surface.desc.Height; + draw.target_color = true; + draw.ui_offset_x = 0; + Render3DAdd(std::move(draw)); + } + // GPU counterpart of rasterize_shadow: the draw goes to the renderer's offscreen pass with the // states rasterize_shadow implements -- a flat D3DRS_TEXTUREFACTOR fill, no culling, no texture, // light, fog or blending, depth test LESSEQUAL with depth writes -- so both paths draw the same map. @@ -1257,6 +1287,8 @@ std::string MtCpuTextureNameFromHandle(const IDirect3DBaseTexture8* handle) const D3DSURFACE_DESC& desc = entry.second->levels[0].desc; if (g_gpu_render_targets && (desc.Usage & D3DUSAGE_RENDERTARGET) && desc.Format == D3DFMT_R5G6B5) return Render3DRenderTargetName(entry.first, desc.Width, desc.Height); + if (g_gpu_render_targets && is_color_target(desc)) + return Render3DRenderTargetName(entry.first, desc.Width, desc.Height, true); if (entry.second == handle && !entry.second->levels.empty()) return "mem:cpu_" + std::to_string(entry.first) + "@" + std::to_string(entry.second->revision); } @@ -1406,9 +1438,9 @@ bool IsNativeTerrainRenderEnabled() void SetGpuRenderTargetsEnabled(bool enabled) { g_gpu_render_targets = enabled; } bool IsGpuRenderTargetsEnabled() { return g_gpu_render_targets; } -std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height) +std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height, bool color) { - return "rt:" + std::to_string(id) + ":" + std::to_string(width) + "x" + std::to_string(height); + return "rt:" + std::to_string(id) + ":" + std::to_string(width) + "x" + std::to_string(height) + (color ? ":c" : ""); } void Render3DBeginFrame() diff --git a/src/platform/EterLib/RenderCommands3D.h b/src/platform/EterLib/RenderCommands3D.h index efcb1dde..2dbdec97 100644 --- a/src/platform/EterLib/RenderCommands3D.h +++ b/src/platform/EterLib/RenderCommands3D.h @@ -38,6 +38,9 @@ struct Render3DDraw { // viewport is then in the target's pixels. Only emitted while GPU render targets are enabled. std::uint32_t render_target = 0; std::uint32_t target_width = 0, target_height = 0; + // PORT: an X8R8G8B8 / A8R8G8B8 colour target (the AVA2 model preview, CRenderTarget) drawn with the + // draw's own states, named with Render3DRenderTargetName(..., true); otherwise the R5G6B5 shadow map. + bool target_color = false; // Stage 0/1 textures, named like UIRenderTextureName: the pack path of a file texture or // "mem:@"; empty when the stage has no texture. @@ -143,7 +146,8 @@ bool LookupGpuSkinSubrange(const void* vertex_buffer_base, std::uint32_t stride, // default (the Godot consumer has no offscreen pass); the native Vulkan renderer turns it on. void SetGpuRenderTargetsEnabled(bool enabled); bool IsGpuRenderTargetsEnabled(); -std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height); +// A colour target's name ends in ":c". +std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height, bool color = false); void Render3DBeginFrame(); void Render3DAdd(Render3DDraw draw); diff --git a/src/platform/ScriptLib/ModelPreviewScript.inc b/src/platform/ScriptLib/ModelPreviewScript.inc new file mode 100644 index 00000000..6fc0f784 --- /dev/null +++ b/src/platform/ScriptLib/ModelPreviewScript.inc @@ -0,0 +1,255 @@ +// PORT: model preview, from AVA2's render-target window (uirendertarget.py). A wearable item is shown on a +// character of every race that may wear it, a monster with its motions; drag rotates, buttons zoom. Entry points: +// X + click on an inventory or shop item, and a "模型预览" line in the wiki detail. The native side is the +// renderTarget module (UserInterface/PythonRenderTargetModule.cpp). Design: docs/FEATURE-MODEL-PREVIEW.md. +static const char c_szModelPreviewScript[] = R"PY(# -*- coding: utf-8 -*- +import app +import chat +import game +import grp +import item +import player +import renderTarget +import ui +import wndMgr +from mt_biolog import Action, Text, L + +INDEX=1 +VIEW_W=300 +VIEW_H=330 +MARGIN=15 +TOP=58 +ROW=54 +ZOOM_MIN=-8 +ZOOM_MAX=12 +ITEM_ZOOM=2 +PITCH_LIMIT=60. +# Paired by job: warrior, assassin, sura, shaman; male first. race%4 is the job, these races are male. +RACES=(0,4,5,1,2,6,7,3) +MALE=(0,5,2,7) +JOBS=('武士','刺客','修罗','法师') +MOTIONS=((1,'待机'),(13,'攻击'),(5,'受击'),(11,'死亡'),(33,'技能1'),(34,'技能2'),(35,'技能3'),(36,'技能4')) + +g_window=None + + +def RaceName(race): + return '%s(%s)'%(JOBS[race%4],'男' if race in MALE else '女') + + +def ItemSpec(vnum): + # (slot, races that may wear it) for a wearable item with a model, None otherwise. + try: + item.SelectItem(vnum) + kind=item.GetItemType();sub=item.GetItemSubType() + except (RuntimeError,TypeError):return None + if kind==item.ITEM_TYPE_WEAPON and sub!=item.WEAPON_ARROW:slot='weapon' + elif kind==item.ITEM_TYPE_ARMOR and sub==item.ARMOR_BODY:slot='armor' + elif kind==item.ITEM_TYPE_COSTUME and sub==item.COSTUME_TYPE_BODY:slot='armor' + elif kind==item.ITEM_TYPE_COSTUME and sub==item.COSTUME_TYPE_HAIR:slot='hair' + else:return None + jobs=(item.ITEM_ANTIFLAG_WARRIOR,item.ITEM_ANTIFLAG_ASSASSIN,item.ITEM_ANTIFLAG_SURA,item.ITEM_ANTIFLAG_SHAMAN) + races=[r for r in RACES if not item.IsAntiFlag(jobs[r%4]) and + not item.IsAntiFlag(item.ITEM_ANTIFLAG_MALE if r in MALE else item.ITEM_ANTIFLAG_FEMALE)] + if not races:return None + return slot,races + + +def Previewable(kind,vnum): + return kind=='mob' or ItemSpec(vnum) is not None + + +class View(ui.Window): + # The render target is drawn over this window; a left drag turns the camera around the model. + def __init__(self,owner): + ui.Window.__init__(self);self.owner=owner;self.drag=None + + def OnMouseLeftButtonDown(self): + x,y=wndMgr.GetMousePosition();o=self.owner;self.drag=(x,y,o.yaw,o.pitch);return True + + def OnMouseLeftButtonUp(self): + self.drag=None;return True + + def OnUpdate(self): + if self.drag: + x,y=wndMgr.GetMousePosition();x0,y0,yaw,pitch=self.drag + self.owner.Turn(yaw+(x-x0)*.5,pitch+(y-y0)*.3) + + def OnRender(self): + x,y=self.GetGlobalPosition();w=self.GetWidth();h=self.GetHeight() + grp.SetColor(0xff000000);grp.RenderBar(x,y,w,h) + renderTarget.RenderTexture(INDEX,x,y,x+w,y+h) + grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,w,h) + + +class PreviewWindow(ui.Window): + def __init__(self): + ui.Window.__init__(self);self.SetWindowName('ModelPreviewWindow');self.AddFlag('float');self.AddFlag('movable') + width=VIEW_W+2*MARGIN;self.SetSize(width,TOP+VIEW_H+ROW+15) + self.heading=Text(self,18,18,'模型预览',0xffffd68b) + self.closeButton=Action(self,width-76,4,64,'关闭',self.Close) + self.view=View(self);self.view.SetParent(self);self.view.SetPosition(MARGIN,TOP);self.view.SetSize(VIEW_W,VIEW_H);self.view.Show() + self.note=Text(self,MARGIN+8,TOP+8,'',0xffc3b7a1) + y=TOP+VIEW_H+6 + self.prevRace=Action(self,MARGIN,y,56,'<',lambda:self.StepRace(-1)) + self.raceName=Text(self,width/2,y+16,'',0xffe8dec8);self.raceName.SetHorizontalAlignCenter() + self.nextRace=Action(self,width-MARGIN-56,y,56,'>',lambda:self.StepRace(1)) + step=(VIEW_W+6)/4 + self.motions=[Action(self,MARGIN+(i%4)*step,y+(i//4)*ROW,step-6,name,(lambda m=motion:self.Motion(m))) + for i,(motion,name) in enumerate(MOTIONS)] + self.controls=[Action(self,MARGIN+i*step,0,step-6,name,event) for i,(name,event) in enumerate(( + ('放大',lambda:self.Zoom(-1)),('缩小',lambda:self.Zoom(1)),('自动旋转',self.ToggleSpin),('复位',self.Apply)))] + self.kind=None;self.vnum=0;self.slot=None;self.races=[];self.race=0 + self.yaw=0.;self.pitch=0.;self.zoom=0;self.spin=False;self.ready=False + self.Hide() + + def Open(self,kind,vnum): + if kind=='mob':slot,races=None,[] + else: + spec=ItemSpec(vnum) + if not spec:return False + slot,races=spec + renderTarget.Remove(INDEX) + if not renderTarget.Create(INDEX,VIEW_W,VIEW_H):return False + renderTarget.SetVisibility(INDEX,True) + self.kind=kind;self.vnum=vnum;self.slot=slot;self.races=races + if races: + try:own=player.GetRace() + except RuntimeError:own=-1 + self.race=own if own in races else races[0] + self.Layout();self.Apply() + if not self.IsShow():self.SetCenterPosition() + ui.Window.Show(self);self.SetTop() + return True + + def Layout(self): + item_mode=self.kind!='mob';rows=1 if item_mode else (len(MOTIONS)+3)//4 + y=TOP+VIEW_H+6 + for w in (self.prevRace,self.nextRace,self.raceName): + if item_mode:w.Show() + else:w.Hide() + for b in self.motions: + if item_mode:b.Hide() + else:b.Show() + bottom=y+rows*ROW + step=(VIEW_W+6)/4 + for i,b in enumerate(self.controls):b.SetPosition(MARGIN+i*step,bottom) + self.SetSize(VIEW_W+2*MARGIN,bottom+ROW+10) + many=len(self.races)>1 + self.prevRace.SetEnabled(many);self.nextRace.SetEnabled(many) + + def Apply(self): + # Rebuilds the model: also the reset button, since SelectModel puts the camera back. + renderTarget.ResetModel(INDEX) + self.yaw=0.;self.pitch=0.;self.zoom=0 + if self.kind=='mob': + self.ready=bool(renderTarget.SelectModel(INDEX,self.vnum)) + note='' if self.ready else '该怪物没有模型' + else: + self.ready=bool(renderTarget.SelectModel(INDEX,self.race)) + if self.ready: + if self.slot=='armor':renderTarget.SetArmor(INDEX,self.vnum) + elif self.slot=='weapon':renderTarget.SetWeapon(INDEX,self.vnum) + else:renderTarget.SetHair(INDEX,self.vnum) + # The default camera cuts the feet of a player race in this view; start two zoom steps out. + if self.ready: + for _ in range(ITEM_ZOOM):renderTarget.Zoom(INDEX,1) + note='' if self.ready else '模型加载失败' + self.raceName.SetText(L(RaceName(self.race))) + renderTarget.SetRotation(INDEX,self.spin) + renderTarget.RotateEyeAroundTarget(INDEX,0.,0.) + self.note.SetText(L(note)) + + def StepRace(self,delta): + if len(self.races)<2:return + self.race=self.races[(self.races.index(self.race)+delta)%len(self.races)];self.Apply() + + def Motion(self,motion): + if self.ready and not renderTarget.SetMotionIndex(INDEX,motion):self.note.SetText(L('该怪物没有此动作')) + else:self.note.SetText('') + + def Zoom(self,direction): + if not self.ready or not ZOOM_MIN<=self.zoom+direction<=ZOOM_MAX:return + self.zoom+=direction;renderTarget.Zoom(INDEX,direction) + + def Turn(self,yaw,pitch): + self.yaw=yaw%360.;self.pitch=max(-PITCH_LIMIT,min(PITCH_LIMIT,pitch)) + renderTarget.RotateEyeAroundTarget(INDEX,self.pitch,self.yaw) + + def ToggleSpin(self): + self.spin=not self.spin;renderTarget.SetRotation(INDEX,self.spin) + self.controls[2].SetText('停止旋转' if self.spin else '自动旋转') + + def Close(self): + self.Hide() + + def Hide(self): + # The target holds a GPU texture and a model; drop both whenever the window is not shown. + # ui.Window.__init__ hides the window before the view exists. + if getattr(self,'view',None):self.view.drag=None + self.ready=False + renderTarget.Remove(INDEX) + ui.Window.Hide(self) + + def OnRender(self): + x,y=self.GetGlobalPosition();grp.SetColor(0xf51b1916);grp.RenderBar(x,y,self.GetWidth(),self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,self.GetWidth(),self.GetHeight()) + + def OnPressEscapeKey(self):self.Hide();return True + + def Destroy(self): + self.Hide();self.closeButton.event=None;self.prevRace.event=self.nextRace.event=None + for b in self.motions+self.controls:b.event=None + self.view.owner=None + + +def Preview(kind,vnum): + if not g_window:return False + if not g_window.Open(kind,vnum): + chat.AppendChat(chat.CHAT_TYPE_INFO,L('该物品没有可预览的模型'));return False + return True + + +def KeyHeld(): + return app.IsPressed(app.DIK_X) + + +_open=game.GameWindow.Open +_close=game.GameWindow.Close + +def Open(self): + global g_window + _open(self);g_window=PreviewWindow() + +def Close(self): + global g_window + if g_window:g_window.Destroy();g_window=None + renderTarget.Destroy() + _close(self) + +game.GameWindow.Open=Open +game.GameWindow.Close=Close + +try: + import uiInventory + _inventorySelect=uiInventory.InventoryWindow.SelectItemSlot + def InventorySelect(self,slot): + if KeyHeld(): + vnum=player.GetItemIndex(self._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(slot)) + if vnum and ItemSpec(vnum):Preview('item',vnum);return + return _inventorySelect(self,slot) + uiInventory.InventoryWindow.SelectItemSlot=InventorySelect +except (ImportError,AttributeError):pass + +try: + import shop + import uiShop + _shopSelect=uiShop.ShopDialog.SelectItemSlot + def ShopSelect(self,slot): + if KeyHeld(): + real=getattr(self,'_ShopDialog__GetRealIndex',None) + vnum=shop.GetItemID(real(slot) if real else slot) + if vnum and ItemSpec(vnum):Preview('item',vnum);return + return _shopSelect(self,slot) + uiShop.ShopDialog.SelectItemSlot=ShopSelect +except (ImportError,AttributeError):pass +)PY"; diff --git a/src/platform/ScriptLib/PythonBoot.cpp b/src/platform/ScriptLib/PythonBoot.cpp index d71147d3..75bc8c6d 100644 --- a/src/platform/ScriptLib/PythonBoot.cpp +++ b/src/platform/ScriptLib/PythonBoot.cpp @@ -44,6 +44,7 @@ #include "UserInterface/PythonSystem.h" #include "UserInterface/AbstractApplication.h" #include "UserInterface/PythonApplication.h" +#include "UserInterface/CRenderTargetManager.h" #include "EterBase/Timer.h" #include "PythonBoot.h" #include "../UserInterface/UserInterface.h" @@ -157,6 +158,7 @@ struct GameSingletons CGuildMarkDownloader kGuildMarkDownloader; CGuildMarkUploader kGuildMarkUploader; CPythonSystem pySystem; // last CPythonApplication member (m_pySystem) + CRenderTargetManager renderTargets; // PORT: AVA2 model preview; destroyed first, its models use the rest }; std::unique_ptr g_game_singletons; bool g_host_hardware_cursor_enabled = false; @@ -911,6 +913,7 @@ bool run_main_script_body(const char* lpCmdLine, std::string* error) initsafebox(); initguild(); initServerStateChecker(); + initRenderTarget(); // PORT: AVA2 model preview init_2v0_gameplay_stubs(); ClearLogBoxMessage(); @@ -1006,6 +1009,7 @@ namespace PythonBoot #include "ChannelScript.inc" #include "BossScript.inc" #include "VipScript.inc" +#include "ModelPreviewScript.inc" static void install_script_module(const char* c_szModule, const char* c_szScript) { @@ -1086,6 +1090,7 @@ bool RunMainScript(const char* lpCmdLine, std::string* error) install_script_module("mt_channel", c_szChannelScript); install_script_module("mt_boss", c_szBossScript); install_script_module("mt_vip", c_szVipScript); + install_script_module("mt_preview", c_szModelPreviewScript); if (g_touch_input) install_script_module("mt_mobile_hud", c_szMobileHUDScript); } diff --git a/src/platform/ScriptLib/WikiScript.inc b/src/platform/ScriptLib/WikiScript.inc index f3ace6d4..78aaa5f2 100644 --- a/src/platform/ScriptLib/WikiScript.inc +++ b/src/platform/ScriptLib/WikiScript.inc @@ -184,6 +184,9 @@ class WikiWindow(ui.Window): if row:self.Select('mobs',row) else:self.QueryRemote('mob',vnum[1],0) elif vnum[0]=='items':self.Open(vnum[1]) + elif vnum[0]=='preview': + import mt_preview + mt_preview.Preview(vnum[1],vnum[2]) return row=self.catalogue.FindItem(vnum) if row:self.Select('items',row) @@ -232,12 +235,15 @@ class WikiWindow(ui.Window): else: cost=self.catalogue.FindItem(offer['cost']) lines.append((u'研究兑换:'+(cost['name'] if cost else u'#%d'%offer['cost'])+u' ×%d'%offer['amount'],offer['cost'] if cost else 0)) + if row['kind'] in ('ITEM_WEAPON','ITEM_ARMOR','ITEM_COSTUME'): + import mt_preview + if mt_preview.Previewable('item',vnum):lines.append((u'模型预览',('preview','item',vnum))) lines.append((u'查看获取来源',('query','sources',vnum))) if row['kind'] in ('ITEM_GIFTBOX','ITEM_TREASURE_BOX'):lines.append((u'宝箱奖励预览',('query','chest',vnum))) else: lines=[(u'类型:'+U('矿石' if row['kind']=='STONE' else '怪物')+u' / '+U(RANK_NAMES.get(row['rank'],'普通')),0),(u'等级:%d 生命:%d'%(row['level'],row['hp']),0), (u'基础攻击:%d—%d'%(row['attack_min'],row['attack_max']),0), - (u'基础防御:%d 经验:%d'%(row['defense'],row['exp']),0),(u'查看服务端掉落列表',('query','mob',vnum))] + (u'基础防御:%d 经验:%d'%(row['defense'],row['exp']),0),(u'模型预览',('preview','mob',vnum)),(u'查看服务端掉落列表',('query','mob',vnum))] if self.remote:lines=self.RemoteLines() self.links=lines pages=max(1,(len(lines)+self.rowCount-1)//self.rowCount) diff --git a/src/platform/UserInterface/PythonApplication.cpp b/src/platform/UserInterface/PythonApplication.cpp index e4933efa..f8a9ffec 100644 --- a/src/platform/UserInterface/PythonApplication.cpp +++ b/src/platform/UserInterface/PythonApplication.cpp @@ -23,6 +23,7 @@ #include "UserInterface/PythonSystem.h" #include "../ScriptLib/PythonBoot.h" #include "ServerClock.h" +#include "UserInterface/CRenderTargetManager.h" #include "../EterBase/PerfCounters.h" #if defined(__linux__) || defined(__ANDROID__) @@ -213,6 +214,7 @@ void CPythonApplication::RenderGame() CCullingManager::Instance().Process(); CPythonCharacterManager::Instance().Deform(); + CRenderTargetManager::Instance().DeformModels(); // PORT: AVA2 model preview CEffectManager::Instance().Update(); if (!SkipDiagnosticRender("characters")) CPythonBackground::Instance().RenderCharacterShadowToTexture(); @@ -239,6 +241,7 @@ void CPythonApplication::RenderGame() CPythonBackground::Instance().SetCharacterDirLight(); if (!SkipDiagnosticRender("characters")) CPythonCharacterManager::Instance().Render(); + CRenderTargetManager::Instance().RenderModels(); // PORT: AVA2, under the character light CPythonBackground::Instance().SetBackgroundDirLight(); CPythonBackground::Instance().RenderWater(); @@ -279,6 +282,8 @@ void CPythonApplication::UpdateGame() s.BuildViewFrustum(); } + CRenderTargetManager::Instance().UpdateModels(); // PORT: AVA2 model preview + TPixelPosition kPPosMainActor; CPythonPlayer::Instance().NEW_GetMainActorPosition(&kPPosMainActor); diff --git a/src/port/EterLib/GrpBase.h b/src/port/EterLib/GrpBase.h index b360d9f5..91af9f60 100644 --- a/src/port/EterLib/GrpBase.h +++ b/src/port/EterLib/GrpBase.h @@ -163,6 +163,7 @@ class CGraphicBase void SetOrtho3D(float hres, float vres, float zmin, float zmax); void SetPerspective(float fov, float aspect, float nearz, float farz); float GetFOV(); + float GetAspect() { return ms_fAspect; } // PORT: AVA2 model preview restores the perspective void GetClipPlane(float * fNearY, float * fFarY) { *fNearY = ms_fNearY; diff --git a/src/port/EterLib/GrpRenderTargetTexture.cpp b/src/port/EterLib/GrpRenderTargetTexture.cpp new file mode 100644 index 00000000..1d06d081 --- /dev/null +++ b/src/port/EterLib/GrpRenderTargetTexture.cpp @@ -0,0 +1,131 @@ +#include "StdAfx.h" +#include "../EterBase/Stl.h" +#include "GrpRenderTargetTexture.h" +#include "StateManager.h" + +CGraphicRenderTargetTexture::CGraphicRenderTargetTexture(): m_d3dFormat{ D3DFMT_UNKNOWN }, m_depthStencilFormat{ D3DFMT_UNKNOWN } +{ + Initialize(); + memset(&m_renderRect, 0, sizeof(m_renderRect)); +} + +CGraphicRenderTargetTexture::~CGraphicRenderTargetTexture() +{ + Reset(); +} + +void CGraphicRenderTargetTexture::ReleaseTextures() +{ + safe_release(m_lpd3dRenderTexture); + safe_release(m_lpd3dRenderTargetSurface); + safe_release(m_lpd3dDepthSurface); + safe_release(m_lpd3dOriginalRenderTarget); + safe_release(m_lpd3dOldDepthBufferSurface); + memset(&m_renderRect, 0, sizeof(m_renderRect)); +} + +bool CGraphicRenderTargetTexture::Create(const int width, const int height, const D3DFORMAT texFormat, const D3DFORMAT depthFormat) +{ + Reset(); + m_height = height; + m_width = width; + if (!CreateRenderTexture(width, height, texFormat)) + return false; + if (!CreateRenderDepthStencil(width, height, depthFormat)) + return false; + return true; +} + +void CGraphicRenderTargetTexture::CreateTextures() +{ + if (CreateRenderTexture(m_width, m_height, m_d3dFormat)) + CreateRenderDepthStencil(m_width, m_height, m_depthStencilFormat); +} + +bool CGraphicRenderTargetTexture::CreateRenderTexture(const int width, const int height, const D3DFORMAT format) +{ + m_d3dFormat = format; + // PORT: one level (AVA2 asked for a full chain, 0). Only level 0 is a render target on the recording + // device, and the preview is drawn 1:1. + if (FAILED(ms_lpd3dDevice->CreateTexture(width, height, 1, D3DUSAGE_RENDERTARGET, m_d3dFormat, D3DPOOL_DEFAULT, &m_lpd3dRenderTexture))) + return false; + if (FAILED(m_lpd3dRenderTexture->GetSurfaceLevel(0, &m_lpd3dRenderTargetSurface))) + return false; + return true; +} + +bool CGraphicRenderTargetTexture::CreateRenderDepthStencil(const int width, const int height, const D3DFORMAT format) +{ + m_depthStencilFormat = format; + + return (ms_lpd3dDevice->CreateDepthStencilSurface(width, height, m_depthStencilFormat, D3DMULTISAMPLE_NONE, &m_lpd3dDepthSurface)) == D3D_OK; +} + +void CGraphicRenderTargetTexture::SetRenderTarget() +{ + ms_lpd3dDevice->GetRenderTarget(&m_lpd3dOriginalRenderTarget); + ms_lpd3dDevice->GetDepthStencilSurface(&m_lpd3dOldDepthBufferSurface); + ms_lpd3dDevice->SetRenderTarget(m_lpd3dRenderTargetSurface, m_lpd3dDepthSurface); +} + +void CGraphicRenderTargetTexture::ResetRenderTarget() +{ + ms_lpd3dDevice->SetRenderTarget(m_lpd3dOriginalRenderTarget, m_lpd3dOldDepthBufferSurface); + + safe_release(m_lpd3dOriginalRenderTarget); + safe_release(m_lpd3dOldDepthBufferSurface); +} + +void CGraphicRenderTargetTexture::Clear() +{ + ms_lpd3dDevice->Clear(0, nullptr, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER, 0x00000000 /* PORT: D3DCOLOR_ARGB(0, 0, 0, 0) */, 1.0f, 0); +} + +void CGraphicRenderTargetTexture::Render() const +{ + D3DSURFACE_DESC desc; + m_lpd3dRenderTargetSurface->GetDesc(&desc); + + const auto imgWidth = static_cast(m_renderRect.right - m_renderRect.left); + const auto imgHeight = static_cast(m_renderRect.bottom - m_renderRect.top); + + const auto su = 0.0f; + const auto sv = 0.0f; + const auto eu = imgWidth / static_cast(desc.Width); + const auto ev = imgHeight / static_cast(desc.Height); + + TPDTVertex pVertices[4]; + + pVertices[0].position = TPosition(m_renderRect.left - 0.5f, m_renderRect.top - 0.5f, 0.0f); + pVertices[0].texCoord = TTextureCoordinate(su, sv); + pVertices[0].diffuse = 0xffffffff; + + pVertices[1].position = TPosition(m_renderRect.right - 0.5f, m_renderRect.top - 0.5f, 0.0f); + pVertices[1].texCoord = TTextureCoordinate(eu, sv); + pVertices[1].diffuse = 0xffffffff; + + pVertices[2].position = TPosition(m_renderRect.left - 0.5f, m_renderRect.bottom - 0.5f, 0.0f); + pVertices[2].texCoord = TTextureCoordinate(su, ev); + pVertices[2].diffuse = 0xffffffff; + + pVertices[3].position = TPosition(m_renderRect.right - 0.5f, m_renderRect.bottom - 0.5f, 0.0f); + pVertices[3].texCoord = TTextureCoordinate(eu, ev); + pVertices[3].diffuse = 0xffffffff; + + if (SetPDTStream(pVertices, 4)) + { + SetDefaultIndexBuffer(DEFAULT_IB_FILL_RECT); + STATEMANAGER.SetTexture(0, GetRenderTargetTexture()); + STATEMANAGER.SetTexture(1, nullptr); + STATEMANAGER.SetVertexShader(D3DFVF_XYZ | D3DFVF_TEX1 | D3DFVF_DIFFUSE); + STATEMANAGER.DrawIndexedPrimitive(D3DPT_TRIANGLELIST, 0, 4, 0, 2); + } +} + +void CGraphicRenderTargetTexture::Reset() +{ + Destroy(); + ReleaseTextures(); + m_d3dFormat = D3DFMT_UNKNOWN; + m_depthStencilFormat = D3DFMT_UNKNOWN; +} diff --git a/src/port/EterLib/GrpRenderTargetTexture.h b/src/port/EterLib/GrpRenderTargetTexture.h new file mode 100644 index 00000000..3c57b237 --- /dev/null +++ b/src/port/EterLib/GrpRenderTargetTexture.h @@ -0,0 +1,43 @@ +#pragma once +// PORT: AVA2 CGraphicRenderTargetTexture (model preview). On the recording device a 32-bit render-target +// texture is a GPU colour target ("rt::x:c"); see platform/EterLib/RecordingDevice.cpp. +#include "GrpBase.h" +#include "GrpTexture.h" + +class CGraphicRenderTargetTexture : public CGraphicTexture +{ +public: + CGraphicRenderTargetTexture(); + virtual ~CGraphicRenderTargetTexture(); + +public: + bool Create(int width, int height, D3DFORMAT texFormat, D3DFORMAT depthFormat); + void CreateTextures(); + bool CreateRenderTexture(int width, int height, D3DFORMAT format); + bool CreateRenderDepthStencil(int width, int height, D3DFORMAT format); + + void SetRenderTarget(); + void ResetRenderTarget(); + + void SetRenderingRect(RECT* rect){ m_renderRect = *rect; } + RECT* GetRenderingRect(){ return &m_renderRect; } + + LPDIRECT3DTEXTURE8 GetRenderTargetTexture() const { return m_lpd3dRenderTexture; } + + void ReleaseTextures(); + static void Clear(); + + void Render() const; + +protected: + void Reset(); + LPDIRECT3DTEXTURE8 m_lpd3dRenderTexture{}; + LPDIRECT3DSURFACE8 m_lpd3dRenderTargetSurface{}; + LPDIRECT3DSURFACE8 m_lpd3dDepthSurface{}; + + LPDIRECT3DSURFACE8 m_lpd3dOriginalRenderTarget{}; + LPDIRECT3DSURFACE8 m_lpd3dOldDepthBufferSurface{}; + D3DFORMAT m_d3dFormat; + D3DFORMAT m_depthStencilFormat; + RECT m_renderRect{}; +}; diff --git a/src/port/UserInterface/CRenderTarget.cpp b/src/port/UserInterface/CRenderTarget.cpp new file mode 100644 index 00000000..b032d250 --- /dev/null +++ b/src/port/UserInterface/CRenderTarget.cpp @@ -0,0 +1,324 @@ +#include "StdAfx.h" +#include "CRenderTarget.h" +#include "InstanceBase.h" + +#include "../EterLib/Camera.h" +#include "../EterLib/GrpImageInstance.h" +#include "../EterLib/ResourceManager.h" +#include "../EterLib/StateManager.h" +#include "../EterPythonLib/PythonGraphic.h" + +CRenderTarget::~CRenderTarget() {} + +CRenderTarget::CRenderTarget(const DWORD width, const DWORD height) : + m_pModel(nullptr), + m_background(nullptr), + m_modelRotation(0), + m_pmodelRotationActive(true), + m_visible(false), + m_bShowModel(true), + m_bScale(1.0), + m_v3AngularVelocity(0.0f, 0.0f, 0.0f), + m_v3Eye(0.0f, 0.0f, 0.0f), + m_v3Target(0.0f, 0.0f, 0.0f), + m_v3Up(0.0f, 0.0f, 1.0f) +{ + auto pTex = std::make_unique(); + if (!pTex->Create(width, height, D3DFMT_X8R8G8B8, D3DFMT_D16)) + { + TraceError("CRenderTarget::CRenderTarget: Could not create CGraphicRenderTargetTexture %dx%d", width, height); + throw std::runtime_error("CRenderTarget::CRenderTarget: Could not create CGraphicRenderTargetTexture"); + } + + m_renderTargetTexture = std::move(pTex); +} + +void CRenderTarget::RenderTexture() const +{ + m_renderTargetTexture->Render(); +} + +void CRenderTarget::SetRenderingRect(RECT* rect) const +{ + m_renderTargetTexture->SetRenderingRect(rect); +} + +void CRenderTarget::CreateTextures() const +{ + m_renderTargetTexture->CreateTextures(); +} + +void CRenderTarget::ReleaseTextures() const +{ + m_renderTargetTexture->ReleaseTextures(); +} + +void CRenderTarget::ResetModel() +{ + // PORT: AVA2 also cleared its wiki affects here; the port has none. + m_pModel.reset(); +} + +void CRenderTarget::SetScale(float fScale) +{ + m_bScale = fScale; + if (IsReady()) + SelectModel(m_pModel->GetRace(), true); +} + +void CRenderTarget::SelectModel(const int index, bool isFromScale) +{ + if (m_pModel && !isFromScale && m_pModel->GetRace() == index) + return; + + if (isFromScale && m_pModel) + m_pModel.reset(); + + CInstanceBase::SCreateData kCreateData{}; + // PORT: AVA2 created every model as TYPE_NPC. Here CActorInstance reserves the armor/weapon/hair parts for + // TYPE_PC only, so a player race created as an NPC crashes on the first ChangeArmor. + kCreateData.m_bType = index < NRaceData::JOB_MAX_NUM * 2 ? CActorInstance::TYPE_PC : CActorInstance::TYPE_NPC; + kCreateData.m_dwRace = index; + auto model = std::make_unique(); + if (!model->Create(kCreateData)) + { + ResetModel(); + return; + } + m_pModel = std::move(model); + // PORT: the preview has no name; the empty text tail Create attaches would follow the world origin. + m_pModel->DetachTextTail(); + m_pModel->NEW_SetPixelPosition(TPixelPosition(0, 0, 0)); + m_pModel->SetRotation(0.0f); + + float fTargetHeight = 110.0f; + + m_v3Eye.x = 0.0f; + m_v3Eye.y = -1000.0f; + m_v3Eye.z = 600.0f; + + m_v3Target.x = 0.0f; + m_v3Target.y = 0.0f; + m_v3Target.z = 95.0f; + + if (index > (NRaceData::JOB_MAX_NUM * 2)) + { + const auto ptrGraphicInstance = m_pModel->GetGraphicThingInstancePtr(); + if (ptrGraphicInstance) { + const float fRaceHeight = (ptrGraphicInstance->GetHeight()/m_bScale); + fTargetHeight = fRaceHeight / 2.0f; + m_v3Eye.y = -(fRaceHeight * 8.9f); + m_v3Eye.z = 0.0f; + m_v3Target.z = fRaceHeight / 2.0f; + } + } + + auto& camera_manager = CCameraManager::instance(); + camera_manager.SetCurrentCamera(CCameraManager::DEFAULT_ORTHO_CAMERA); + camera_manager.GetCurrentCamera()->SetTargetHeight(fTargetHeight); + camera_manager.ResetToPreviousCamera(); + m_modelRotation = 0.0f; + m_v3AngularVelocity = D3DXVECTOR3(0.0f, 0.0f, 0.0f); +} + +bool CRenderTarget::CreateBackground(const char* imgPath, const DWORD width, const DWORD height) +{ + if (m_background) + return false; + + m_background = std::make_unique(); + + const auto graphic_image = dynamic_cast(CResourceManager::instance().GetResourcePointer(imgPath)); + if (!graphic_image) + { + m_background.reset(); + return false; + } + + m_background->SetImagePointer(graphic_image); + return true; +} + +// PORT: AVA2 drew its background in a pass of its own through CGraphicImageInstance::Render. The port's image +// instance goes to the UI command list, not the device, so RenderModel draws the background into the target +// itself: one textured quad that fills it under a target-sized ortho. +void CRenderTarget::RenderBackground() const +{ + if (!m_background || m_background->IsEmpty()) + return; + CGraphicTexture* texture = m_background->GetTexturePointer(); + if (!texture) + return; + + const auto& rectRender = *m_renderTargetTexture->GetRenderingRect(); + const auto width = static_cast(rectRender.right - rectRender.left); + const auto height = static_cast(rectRender.bottom - rectRender.top); + + auto& python_graphic = CPythonGraphic::Instance(); + python_graphic.PushState(); + STATEMANAGER.SaveTransform(D3DTS_WORLD, &CGraphicBase::GetIdentityMatrix()); + STATEMANAGER.SetTransform(D3DTS_VIEW, &CGraphicBase::GetIdentityMatrix()); + python_graphic.SetOrtho2D(width, height, 1.0f); + + STATEMANAGER.SaveRenderState(D3DRS_ZENABLE, FALSE); + STATEMANAGER.SaveRenderState(D3DRS_ZWRITEENABLE, FALSE); + STATEMANAGER.SaveRenderState(D3DRS_LIGHTING, FALSE); + STATEMANAGER.SaveRenderState(D3DRS_FOGENABLE, FALSE); + STATEMANAGER.SaveRenderState(D3DRS_ALPHABLENDENABLE, FALSE); + STATEMANAGER.SaveRenderState(D3DRS_ALPHATESTENABLE, FALSE); + STATEMANAGER.SaveRenderState(D3DRS_CULLMODE, D3DCULL_NONE); + STATEMANAGER.SaveTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); + STATEMANAGER.SaveTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1); + STATEMANAGER.SaveTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_DISABLE); + STATEMANAGER.SaveTextureStageState(0, D3DTSS_MINFILTER, D3DTEXF_LINEAR); + STATEMANAGER.SaveTextureStageState(0, D3DTSS_MAGFILTER, D3DTEXF_LINEAR); + STATEMANAGER.SaveTextureStageState(0, D3DTSS_MIPFILTER, D3DTEXF_NONE); + STATEMANAGER.SaveTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE); + + TPDTVertex vertices[4]; + const float x[4] = { 0.0f, width, 0.0f, width }; + const float y[4] = { 0.0f, 0.0f, height, height }; + for (int i = 0; i < 4; ++i) + { + vertices[i].position = TPosition(x[i], y[i], 0.0f); + vertices[i].texCoord = TTextureCoordinate(x[i] / width, y[i] / height); + vertices[i].diffuse = 0xffffffff; + } + STATEMANAGER.SetTexture(0, texture->GetD3DTexture()); + STATEMANAGER.SetTexture(1, nullptr); + STATEMANAGER.SetVertexShader(D3DFVF_XYZ | D3DFVF_TEX1 | D3DFVF_DIFFUSE); + STATEMANAGER.DrawPrimitiveUP(D3DPT_TRIANGLESTRIP, 2, vertices, sizeof(TPDTVertex)); + + STATEMANAGER.RestoreTextureStageState(1, D3DTSS_COLOROP); + STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MIPFILTER); + STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MAGFILTER); + STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MINFILTER); + STATEMANAGER.RestoreTextureStageState(0, D3DTSS_ALPHAOP); + STATEMANAGER.RestoreTextureStageState(0, D3DTSS_COLOROP); + STATEMANAGER.RestoreTextureStageState(0, D3DTSS_COLORARG1); + STATEMANAGER.RestoreRenderState(D3DRS_CULLMODE); + STATEMANAGER.RestoreRenderState(D3DRS_ALPHATESTENABLE); + STATEMANAGER.RestoreRenderState(D3DRS_ALPHABLENDENABLE); + STATEMANAGER.RestoreRenderState(D3DRS_FOGENABLE); + STATEMANAGER.RestoreRenderState(D3DRS_LIGHTING); + STATEMANAGER.RestoreRenderState(D3DRS_ZWRITEENABLE); + STATEMANAGER.RestoreRenderState(D3DRS_ZENABLE); + STATEMANAGER.RestoreTransform(D3DTS_WORLD); + python_graphic.PopState(); +} + +void CRenderTarget::UpdateModel() +{ + if (!IsReady()) + return; + + if (m_pmodelRotationActive && m_bShowModel) + { + if (m_modelRotation < 360.0f) + m_modelRotation += 1.0f / 60 * 60.0f; + else + m_modelRotation = 0.0f / 60 * 60.0f; + + m_pModel->SetRotation(m_modelRotation); + } + else + m_pModel->SetRotation(0.0f); + m_pModel->Transform(); + if(m_pmodelRotationActive) + m_pModel->GetGraphicThingInstanceRef().RotationProcess(); +} + +void CRenderTarget::DeformModel() +{ + if (!IsReady()) + return; + // PORT: stands in for AVA2's SetAlwaysRender. The model sits at the world origin, so the culling pass hides it. + m_pModel->GetGraphicThingInstanceRef().Show(); + m_pModel->Deform(); +} + +void CRenderTarget::RenderModel() +{ + if (!IsReady()) + return; + + auto& python_graphic = CPythonGraphic::Instance(); + auto& camera_manager = CCameraManager::instance(); + auto& state_manager = CStateManager::Instance(); + auto& rectRender = *m_renderTargetTexture->GetRenderingRect(); + + const auto width = static_cast(rectRender.right - rectRender.left); + const auto height = static_cast(rectRender.bottom - rectRender.top); + // PORT: nothing to draw before the window has given its rect. + if (width <= 0.0f || height <= 0.0f) + return; + + m_renderTargetTexture->SetRenderTarget(); + m_renderTargetTexture->Clear(); + python_graphic.SetViewport(0.0f, 0.0f, width, height); + RenderBackground(); + + python_graphic.ClearDepthBuffer(); + + const auto fov = python_graphic.GetFOV(); + const auto aspect = python_graphic.GetAspect(); + float near_y, far_y; + python_graphic.GetClipPlane(&near_y, &far_y); + + state_manager.SetRenderState(D3DRS_FOGENABLE, FALSE); + + python_graphic.PushState(); + + camera_manager.SetCurrentCamera(CCameraManager::DEFAULT_ORTHO_CAMERA); + camera_manager.GetCurrentCamera()->SetViewParams(m_v3Eye, m_v3Target, m_v3Up); + camera_manager.GetCurrentCamera()->RotateEyeAroundTarget(m_v3AngularVelocity.z, m_v3AngularVelocity.x); + + python_graphic.UpdateViewMatrix(); + python_graphic.SetPerspective(10.0f, width / height, 100.0f, 15000.0f); + + if (m_bShowModel) + { + m_pModel->GetGraphicThingInstanceRef().Show(); + m_pModel->Update(); + m_pModel->Render(); + } + // PORT: AVA2's RenderAllAttachingEffectWiki (attached effects) is not ported. + + camera_manager.ResetToPreviousCamera(); + python_graphic.RestoreViewport(); + python_graphic.PopState(); + python_graphic.SetPerspective(fov, aspect, near_y, far_y); + + m_renderTargetTexture->ResetRenderTarget(); + + state_manager.SetRenderState(D3DRS_FOGENABLE, TRUE); +} + +void CRenderTarget::SetModelV3Eye(float x, float y, float z) +{ + m_v3Eye.x = x; + m_v3Eye.y = y; + m_v3Eye.z = z; +} + +void CRenderTarget::SetModelV3Target(float x, float y, float z) +{ + m_v3Target.x = x; + m_v3Target.y = y; + m_v3Target.z = z; +} + +void CRenderTarget::RotateEyeAroundTarget(float fPitchDegree, float fRollDegree) +{ + m_v3AngularVelocity.x = fRollDegree; + m_v3AngularVelocity.z = fPitchDegree; +} + +void CRenderTarget::Zoom(float fRatio) +{ + if (fRatio == 1.0f) + return; + D3DXVECTOR3 v3Temp = m_v3Eye - m_v3Target; + v3Temp *= fRatio; + m_v3Eye = v3Temp + m_v3Target; +} diff --git a/src/port/UserInterface/CRenderTarget.h b/src/port/UserInterface/CRenderTarget.h new file mode 100644 index 00000000..a8f6fa9c --- /dev/null +++ b/src/port/UserInterface/CRenderTarget.h @@ -0,0 +1,62 @@ +#pragma once +// PORT: AVA2 CRenderTarget (EterLib/CRenderTarget.h): one model drawn into its own colour render target, which +// a UI window then shows. It lives here, not in EterLib, because it owns a CInstanceBase. +#include +#include "../EterLib/GrpRenderTargetTexture.h" + +class CInstanceBase; +class CGraphicImageInstance; + +class CRenderTarget +{ +public: + CRenderTarget(DWORD width, DWORD height); + ~CRenderTarget(); + + void SetVisibility(bool isShow){ m_visible = isShow; } + void SetRotation(bool bRot) { m_pmodelRotationActive = bRot; } + bool IsReady() { return (m_visible && m_pModel); } + std::unique_ptr& GetModel() { return m_pModel; } + + void RotateEyeAroundTarget(float fPitchDegree, float fRollDegree); + void Zoom(float fRatio); + + void SelectModel(int index, bool isFromScale= false); + void ResetModel(); + + void UpdateModel(); + void DeformModel(); + void RenderModel(); + void SetRenderingRect(RECT* rect) const; + + void CreateTextures() const; + void ReleaseTextures() const; + void RenderTexture() const; + + bool CreateBackground(const char* imgPath, DWORD width, DWORD height); + void RenderBackground() const; + + void SetModelV3Eye(float x, float y, float z); + void SetModelV3Target(float x, float y, float z); + + void SetModelStatus(bool bStatus) { m_bShowModel = bStatus; } + + float GetScale() { return m_bScale; } + void SetScale(float fScale); + + bool IsShow() { return m_visible; } + +private: + std::unique_ptr m_pModel; + std::unique_ptr m_background; + std::unique_ptr m_renderTargetTexture; + float m_modelRotation; + bool m_pmodelRotationActive; + bool m_visible; + bool m_bShowModel; + float m_bScale; + D3DXVECTOR3 m_v3AngularVelocity; + D3DXVECTOR3 m_v3Eye; + D3DXVECTOR3 m_v3Target; + D3DXVECTOR3 m_v3Up; +}; diff --git a/src/port/UserInterface/CRenderTargetManager.cpp b/src/port/UserInterface/CRenderTargetManager.cpp new file mode 100644 index 00000000..1895af81 --- /dev/null +++ b/src/port/UserInterface/CRenderTargetManager.cpp @@ -0,0 +1,74 @@ +#include "StdAfx.h" +#include "CRenderTargetManager.h" +#include "../EterBase/Stl.h" +#include + +CRenderTargetManager::CRenderTargetManager(){} +CRenderTargetManager::~CRenderTargetManager() +{ + Destroy(); +} +void CRenderTargetManager::Destroy() +{ + m_renderTargets.clear(); +} +void CRenderTargetManager::DeformModels() +{ + for (const auto& elem : m_renderTargets) + elem.second->DeformModel(); +} +void CRenderTargetManager::UpdateModels() +{ + for (auto& elem : m_renderTargets) + elem.second->UpdateModel(); +} +// PORT: the background is drawn into the target by RenderModel (CRenderTarget::RenderBackground). +void CRenderTargetManager::RenderBackgrounds() +{ +} +void CRenderTargetManager::RenderModels() +{ + for (const auto& elem : m_renderTargets) + elem.second->RenderModel(); +} +void CRenderTargetManager::CreateRenderTargetTextures() +{ + for (const auto& elem : m_renderTargets) + elem.second->CreateTextures(); +} +void CRenderTargetManager::ReleaseRenderTargetTextures() +{ + for (const auto& elem : m_renderTargets) + elem.second->ReleaseTextures(); +} +bool CRenderTargetManager::CreateRenderTarget(const int index, const int width, const int height) +{ + // PORT: the size check guards the recording device, which allocates the target's bytes up front. + if (index < 1 || GetRenderTarget(index) || width < 1 || height < 1 || width > 2048 || height > 2048) + return false; + try + { + m_renderTargets.emplace(index, std::make_shared(width, height)); + } + catch (const std::exception&) + { + return false; + } + return true; +} +void CRenderTargetManager::RemoveRenderTarget(const int index) +{ + const auto it = m_renderTargets.find(index); + if (it != m_renderTargets.end()) + { + it->second->ResetModel(); + m_renderTargets.erase(index); + } +} +std::shared_ptr CRenderTargetManager::GetRenderTarget(const int index) +{ + const auto it = m_renderTargets.find(index); + if (it != m_renderTargets.end()) + return it->second; + return nullptr; +} diff --git a/src/port/UserInterface/CRenderTargetManager.h b/src/port/UserInterface/CRenderTargetManager.h new file mode 100644 index 00000000..10ff8795 --- /dev/null +++ b/src/port/UserInterface/CRenderTargetManager.h @@ -0,0 +1,31 @@ +#pragma once +// PORT: AVA2 CRenderTargetManager (EterLib/CRenderTargetManager.h). +#include +#include + +#include "../EterBase/Singleton.h" +#include +#include "CRenderTarget.h" + +class CRenderTargetManager : public CSingleton +{ +public: + CRenderTargetManager(); + virtual ~CRenderTargetManager(); + + std::shared_ptr GetRenderTarget(int index); + bool CreateRenderTarget(int index, int width, int height); + + void CreateRenderTargetTextures(); + void ReleaseRenderTargetTextures(); + + void RemoveRenderTarget(const int index); + void Destroy(); + void DeformModels(); + void UpdateModels(); + void RenderBackgrounds(); + void RenderModels(); + +protected: + std::unordered_map> m_renderTargets; +}; diff --git a/src/port/UserInterface/PythonRenderTargetModule.cpp b/src/port/UserInterface/PythonRenderTargetModule.cpp new file mode 100644 index 00000000..5b20893f --- /dev/null +++ b/src/port/UserInterface/PythonRenderTargetModule.cpp @@ -0,0 +1,282 @@ +// PORT: AVA2 CPythonRenderTargetModule (module renderTarget), the subset the model preview uses. AVA2's window +// manager created the target for a registered window and drew it; here Create and RenderTexture do that, called +// from the script's own window (ScriptLib/ModelPreviewScript.inc). +#include "StdAfx.h" +#include "CRenderTargetManager.h" +#include "InstanceBase.h" +#include "../GameLib/ItemManager.h" + +namespace +{ +// The default of CPythonApplication's camera zoom speed (PythonApplicationCamera.cpp), which AVA2 read. +const float c_fRenderTargetZoomSpeed = 0.05f; + +std::shared_ptr Target(PyObject* poArgs) +{ + int iIndex; + if (!PyTuple_GetInteger(poArgs, 0, &iIndex)) + return nullptr; + return CRenderTargetManager::Instance().GetRenderTarget(iIndex); +} + +bool Integer(PyObject* poArgs, int pos, int* value) +{ + return PyTuple_GetInteger(poArgs, pos, value); +} +} + +PyObject* renderTargetCreate(PyObject* poSelf, PyObject* poArgs) +{ + int iIndex, iWidth, iHeight; + if (!Integer(poArgs, 0, &iIndex) || !Integer(poArgs, 1, &iWidth) || !Integer(poArgs, 2, &iHeight)) + return Py_BadArgument(); + return Py_BuildValue("i", CRenderTargetManager::Instance().CreateRenderTarget(iIndex, iWidth, iHeight) ? 1 : 0); +} + +PyObject* renderTargetRemove(PyObject* poSelf, PyObject* poArgs) +{ + int iIndex; + if (!Integer(poArgs, 0, &iIndex)) + return Py_BadArgument(); + CRenderTargetManager::Instance().RemoveRenderTarget(iIndex); + return Py_BuildNone(); +} + +PyObject* renderTargetExists(PyObject* poSelf, PyObject* poArgs) +{ + int iIndex; + if (!Integer(poArgs, 0, &iIndex)) + return Py_BadArgument(); + return Py_BuildValue("i", CRenderTargetManager::Instance().GetRenderTarget(iIndex) ? 1 : 0); +} + +PyObject* renderTargetRenderTexture(PyObject* poSelf, PyObject* poArgs) +{ + int l, t, r, b; + if (!Integer(poArgs, 1, &l) || !Integer(poArgs, 2, &t) || !Integer(poArgs, 3, &r) || !Integer(poArgs, 4, &b)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target && target->IsShow()) + { + RECT rect = { l, t, r, b }; + target->SetRenderingRect(&rect); + target->RenderTexture(); + } + return Py_BuildNone(); +} + +PyObject* renderTargetSelectModel(PyObject* poSelf, PyObject* poArgs) +{ + int modelIndex; + if (!Integer(poArgs, 1, &modelIndex)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target) + target->SelectModel(modelIndex); + return Py_BuildValue("i", target && target->GetModel() ? 1 : 0); +} + +PyObject* renderTargetSetArmor(PyObject* poSelf, PyObject* poArgs) +{ + int iItemVnum; + if (!Integer(poArgs, 1, &iItemVnum)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target && target->IsReady()) + target->GetModel()->ChangeArmor(iItemVnum); + return Py_BuildNone(); +} + +PyObject* renderTargetSetWeapon(PyObject* poSelf, PyObject* poArgs) +{ + int iItemVnum; + if (!Integer(poArgs, 1, &iItemVnum)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target && target->IsReady()) + target->GetModel()->ChangeWeapon(iItemVnum); + return Py_BuildNone(); +} + +PyObject* renderTargetSetHair(PyObject* poSelf, PyObject* poArgs) +{ + int iItemVnum; + if (!Integer(poArgs, 1, &iItemVnum)) + return Py_BadArgument(); + bool isValue = false; + const auto target = Target(poArgs); + if (target) + { + PyTuple_GetBoolean(poArgs, 2, &isValue); + if (!isValue) + { + CItemData* pItemData; + if (!CItemManager::Instance().GetItemDataPointer(iItemVnum, &pItemData)) + return Py_BuildNone(); + iItemVnum = pItemData->GetValue(3); + } + if (target->IsReady()) + target->GetModel()->SetHair(iItemVnum); + } + return Py_BuildNone(); +} + +PyObject* renderTargetSetMount(PyObject* poSelf, PyObject* poArgs) +{ + int iRace; + if (!Integer(poArgs, 1, &iRace)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target && target->IsReady()) + target->GetModel()->MountHorse(iRace); + return Py_BuildNone(); +} + +PyObject* renderTargetSetRotation(PyObject* poSelf, PyObject* poArgs) +{ + bool bRot; + if (!PyTuple_GetBoolean(poArgs, 1, &bRot)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target) + target->SetRotation(bRot); + return Py_BuildNone(); +} + +PyObject* renderTargetSetVisibility(PyObject* poSelf, PyObject* poArgs) +{ + bool isShow; + if (!PyTuple_GetBoolean(poArgs, 1, &isShow)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target) + target->SetVisibility(isShow); + return Py_BuildNone(); +} + +PyObject* renderTargetSetBackground(PyObject* poSelf, PyObject* poArgs) +{ + char* szPathName; + if (!PyTuple_GetString(poArgs, 1, &szPathName)) + return Py_BadArgument(); + const auto target = Target(poArgs); + // PORT: the background is stretched over the target when drawn, so the sizes AVA2 passed are unused. + return Py_BuildValue("i", target && target->CreateBackground(szPathName, 0, 0) ? 1 : 0); +} + +PyObject* renderTargetResetModel(PyObject* poSelf, PyObject* poArgs) +{ + const auto target = Target(poArgs); + if (target) + target->ResetModel(); + return Py_BuildNone(); +} + +PyObject* renderTargetDestroy(PyObject* poSelf, PyObject* poArgs) +{ + CRenderTargetManager::Instance().Destroy(); + return Py_BuildNone(); +} + +PyObject* renderTargetSetModelV3Eye(PyObject* poSelf, PyObject* poArgs) +{ + float x, y, z; + if (!PyTuple_GetFloat(poArgs, 1, &x) || !PyTuple_GetFloat(poArgs, 2, &y) || !PyTuple_GetFloat(poArgs, 3, &z)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target) + target->SetModelV3Eye(x, y, z); + return Py_BuildNone(); +} + +PyObject* renderTargetSetModelV3Target(PyObject* poSelf, PyObject* poArgs) +{ + float x, y, z; + if (!PyTuple_GetFloat(poArgs, 1, &x) || !PyTuple_GetFloat(poArgs, 2, &y) || !PyTuple_GetFloat(poArgs, 3, &z)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target) + target->SetModelV3Target(x, y, z); + return Py_BuildNone(); +} + +PyObject* renderTargetZoom(PyObject* poSelf, PyObject* poArgs) +{ + int iDirection; + if (!Integer(poArgs, 1, &iDirection)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target && target->IsReady()) + target->Zoom(1.0f + c_fRenderTargetZoomSpeed * float(iDirection)); + return Py_BuildNone(); +} + +PyObject* renderTargetRotateEyeAroundTarget(PyObject* poSelf, PyObject* poArgs) +{ + float fPitch, fRoll; + if (!PyTuple_GetFloat(poArgs, 1, &fPitch) || !PyTuple_GetFloat(poArgs, 2, &fRoll)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target && target->IsReady()) + target->RotateEyeAroundTarget(fPitch, fRoll); + return Py_BuildNone(); +} + +PyObject* renderTargetSetScale(PyObject* poSelf, PyObject* poArgs) +{ + float fScale; + if (!PyTuple_GetFloat(poArgs, 1, &fScale)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target) + target->SetScale(fScale); + return Py_BuildNone(); +} + +PyObject* renderTargetIsShow(PyObject* poSelf, PyObject* poArgs) +{ + const auto target = Target(poArgs); + return Py_BuildValue("i", target ? target->IsShow() : 0); +} + +PyObject* renderTargetSetMotionIndex(PyObject* poSelf, PyObject* poArgs) +{ + int iMotionIndex; + if (!Integer(poArgs, 1, &iMotionIndex)) + return Py_BadArgument(); + const auto target = Target(poArgs); + if (target && target->IsReady()) + return Py_BuildValue("i", target->GetModel()->GetGraphicThingInstanceRef().InterceptOnceMotion(iMotionIndex) ? 1 : 0); + return Py_BuildValue("i", 0); +} + +void initRenderTarget() +{ + static PyMethodDef s_methods[] = + { + { "Create", renderTargetCreate, METH_VARARGS }, + { "Remove", renderTargetRemove, METH_VARARGS }, + { "Exists", renderTargetExists, METH_VARARGS }, + { "RenderTexture", renderTargetRenderTexture, METH_VARARGS }, + { "SelectModel", renderTargetSelectModel, METH_VARARGS }, + { "SetVisibility", renderTargetSetVisibility, METH_VARARGS }, + { "SetBackground", renderTargetSetBackground, METH_VARARGS }, + { "SetArmor", renderTargetSetArmor, METH_VARARGS }, + { "SetWeapon", renderTargetSetWeapon, METH_VARARGS }, + { "SetHair", renderTargetSetHair, METH_VARARGS }, + { "SetMount", renderTargetSetMount, METH_VARARGS }, + { "SetRotation", renderTargetSetRotation, METH_VARARGS }, + { "ResetModel", renderTargetResetModel, METH_VARARGS }, + { "Destroy", renderTargetDestroy, METH_VARARGS }, + { "SetModelV3Eye", renderTargetSetModelV3Eye, METH_VARARGS }, + { "SetModelV3Target", renderTargetSetModelV3Target, METH_VARARGS }, + { "Zoom", renderTargetZoom, METH_VARARGS }, + { "RotateEyeAroundTarget", renderTargetRotateEyeAroundTarget, METH_VARARGS }, + { "SetScale", renderTargetSetScale, METH_VARARGS }, + { "IsShow", renderTargetIsShow, METH_VARARGS }, + { "SetMotionIndex", renderTargetSetMotionIndex, METH_VARARGS }, + { NULL, NULL, 0 }, + }; + + Py_InitModule("renderTarget", s_methods); +} diff --git a/src/port/UserInterface/StdAfx.h b/src/port/UserInterface/StdAfx.h index e5ac6a27..9865982f 100644 --- a/src/port/UserInterface/StdAfx.h +++ b/src/port/UserInterface/StdAfx.h @@ -70,6 +70,7 @@ void initMessenger(); void initsafebox(); void initguild(); void initServerStateChecker(); +void initRenderTarget(); // PORT: AVA2 CPythonRenderTargetModule extern const std::string& ApplicationStringTable_GetString(DWORD dwID); extern const std::string& ApplicationStringTable_GetString(DWORD dwID, LPCSTR szKey); diff --git a/tests/wiki/test_model_preview.py b/tests/wiki/test_model_preview.py new file mode 100644 index 00000000..fe57f559 --- /dev/null +++ b/tests/wiki/test_model_preview.py @@ -0,0 +1,190 @@ +import sys +import types +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] + +def script(name):return (ROOT/'src/platform/ScriptLib'/name).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0] + + +class Widget(object): + def __init__(self,*a,**k): + self.Hide() # like ui.Window, before the subclass sets anything + self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0);self.enabled=True + def __getattr__(self,name):return lambda *a,**k:None + def Show(self):self.shown=True + def Hide(self):self.shown=False + def IsShow(self):return self.shown + def SetText(self,text):self.text=text + def SetPosition(self,x,y):self.pos=(x,y) + def GetGlobalPosition(self):return self.pos + def SetSize(self,w,h):self.size=(w,h) + def GetWidth(self):return self.size[0] + def GetHeight(self):return self.size[1] + + +class Action(Widget): + def __init__(self,parent,x,y,width,text,event): + Widget.__init__(self);self.pos=(x,y);self.event=event;self.text=text;self.shown=True + def SetEnabled(self,enabled):self.enabled=enabled + + +# vnum -> (type, subtype, antiflags) +ITEMS={11299:(1,0,0),11499:(1,0,0x08|0x10|0x20),12049:(2,0,0x04|0x10|0x20|0x40),8000:(1,6,0), + 41001:(28,0,0x01),45001:(28,1,0x02),27001:(3,0,0),11259:(2,1,0)} + + +class Targets(object): + """renderTarget stub: what exists, and every call in order.""" + def __init__(self):self.live=set();self.calls=[];self.models={};self.missing=set();self.motionless=set() + def __getattr__(self,name): + def call(*a):self.calls.append((name,)+a) + return call + def Create(self,i,w,h):self.calls.append(('Create',i,w,h));self.live.add(i);return 1 + def Remove(self,i):self.calls.append(('Remove',i));self.live.discard(i);self.models.pop(i,None) + def Destroy(self):self.calls.append(('Destroy',));self.live.clear();self.models.clear() + def SelectModel(self,i,race): + self.calls.append(('SelectModel',i,race)) + if race in self.missing:return 0 + self.models[i]=race;return 1 + def SetMotionIndex(self,i,m):self.calls.append(('SetMotionIndex',i,m));return 0 if m in self.motionless else 1 + def named(self,name):return [c for c in self.calls if c[0]==name] + + +class PreviewTests(unittest.TestCase): + def setUp(self): + self.chat=[];self.keys=set();self.mouse=(0,0);self.race=0;self.selected=None + self.rt=Targets() + names=('app','chat','game','grp','item','player','renderTarget','ui','wndMgr','mt_biolog','uiInventory','uiShop','shop') + mods={n:types.ModuleType(n) for n in names} + mods['renderTarget']=self.rt + app=mods['app'];app.DIK_X=45;app.IsPressed=lambda k:k in self.keys + mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m) + mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None)) + grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None + it=mods['item'] + for k,v in dict(ITEM_TYPE_WEAPON=1,ITEM_TYPE_ARMOR=2,ITEM_TYPE_COSTUME=28,WEAPON_ARROW=6,ARMOR_BODY=0, + COSTUME_TYPE_BODY=0,COSTUME_TYPE_HAIR=1,ITEM_ANTIFLAG_FEMALE=0x01,ITEM_ANTIFLAG_MALE=0x02, + ITEM_ANTIFLAG_WARRIOR=0x04,ITEM_ANTIFLAG_ASSASSIN=0x08,ITEM_ANTIFLAG_SURA=0x10, + ITEM_ANTIFLAG_SHAMAN=0x20).items():setattr(it,k,v) + def select(v):self.selected=ITEMS.get(v,ITEMS[27001]) + it.SelectItem=select;it.GetItemType=lambda:self.selected[0];it.GetItemSubType=lambda:self.selected[1] + it.IsAntiFlag=lambda f:bool(self.selected[2]&f) + mods['player'].GetRace=lambda:self.race + self.bag={3:11299,4:27001} + mods['player'].GetItemIndex=lambda cell:self.bag.get(cell,0) + mods['ui'].Window=Widget + mods['wndMgr'].GetMousePosition=lambda:self.mouse + mods['mt_biolog'].Action=Action;mods['mt_biolog'].L=lambda t:t + mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:self.text(t) + self.inventoryClicks=[];self.shopClicks=[] + mods['uiInventory'].InventoryWindow=type('InventoryWindow',(),dict( + SelectItemSlot=lambda s,slot:self.inventoryClicks.append(slot), + _InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos=lambda s,slot:slot+0)) + mods['uiShop'].ShopDialog=type('ShopDialog',(),dict(SelectItemSlot=lambda s,slot:self.shopClicks.append(slot), + _ShopDialog__GetRealIndex=lambda s,slot:40+slot)) + mods['shop'].GetItemID=lambda i:{41:12049,42:27001}.get(i,0) + self.previous={n:sys.modules.get(n) for n in mods} + sys.modules.update(mods) + self.ns={'__name__':'mt_preview'};exec(script('ModelPreviewScript.inc'),self.ns) + self.game=mods['game'];self.gw=self.game.GameWindow();self.game.GameWindow.Open(self.gw) + self.w=self.ns['g_window'] + + def text(self,t): + w=Widget();w.text=t;return w + + def tearDown(self): + for name,value in self.previous.items(): + if value is None:sys.modules.pop(name,None) + else:sys.modules[name]=value + + def test_item_spec(self): + spec=self.ns['ItemSpec'] + self.assertEqual(spec(11299),('weapon',[0,4,5,1,2,6,7,3])) + self.assertEqual(spec(11499),('weapon',[0,4])) # warrior only + self.assertEqual(spec(12049),('armor',[5,1])) # assassin only + self.assertEqual(spec(41001),('armor',[0,5,2,7])) # anti-female costume + self.assertEqual(spec(45001),('hair',[4,1,6,3])) + for vnum in (8000,27001,11259):self.assertIsNone(spec(vnum)) # arrow, potion, helmet + self.assertTrue(self.ns['Previewable']('mob',101));self.assertFalse(self.ns['Previewable']('item',27001)) + + def test_item_preview_races_and_reset(self): + self.race=2 + self.assertTrue(self.ns['Preview']('item',11299));self.assertTrue(self.w.IsShow()) + self.assertEqual(self.rt.live,{1});self.assertIn(('Create',1,300,330),self.rt.calls) + self.assertEqual(self.rt.models[1],2);self.assertEqual(self.rt.named('SetWeapon')[-1],('SetWeapon',1,11299)) + self.assertEqual(self.w.raceName.text,'修罗(男)');self.assertEqual(self.rt.named('Zoom'),[('Zoom',1,1)]*2) + self.w.nextRace.event();self.assertEqual(self.rt.models[1],6);self.assertEqual(self.w.raceName.text,'修罗(女)') + self.w.prevRace.event();self.w.prevRace.event();self.assertEqual(self.rt.models[1],1) + # The player's own race is not allowed: the first allowed race is shown, and race buttons need two races. + self.ns['Preview']('item',41001);self.assertEqual(self.rt.models[1],2) + self.assertEqual(self.rt.named('SetArmor')[-1],('SetArmor',1,41001)) + self.ns['Preview']('item',12049);self.assertEqual(self.rt.models[1],5) + self.ns['Preview']('item',45001);self.assertEqual(self.rt.models[1],4) + self.assertEqual(self.rt.named('SetHair')[-1],('SetHair',1,45001)) + self.assertTrue(self.w.nextRace.enabled) + self.ns['Preview']('item',11499);self.race=0;self.ns['Preview']('item',11499) + self.assertEqual(self.rt.models[1],0) + self.assertFalse(self.ns['Preview']('item',27001));self.assertEqual(self.chat,['该物品没有可预览的模型']) + self.assertEqual(self.rt.models[1],0) # the window keeps what it showed + + def test_mob_motions_zoom_turn(self): + self.rt.motionless.add(34) + self.assertTrue(self.ns['Preview']('mob',101));self.assertEqual(self.rt.models[1],101) + self.assertFalse(self.w.prevRace.IsShow());self.assertTrue(all(b.IsShow() for b in self.w.motions)) + self.w.motions[1].event();self.assertEqual(self.rt.named('SetMotionIndex')[-1],('SetMotionIndex',1,13)) + self.w.motions[5].event();self.assertEqual(self.w.note.text,'该怪物没有此动作') + self.w.motions[0].event();self.assertEqual(self.w.note.text,'') + zoom_in,zoom_out,spin,reset=[b.event for b in self.w.controls] + for _ in range(20):zoom_in() + self.assertEqual(self.w.zoom,-8);self.assertEqual(len(self.rt.named('Zoom')),8) + for _ in range(30):zoom_out() + self.assertEqual(self.w.zoom,12);self.assertEqual(len(self.rt.named('Zoom')),28) + spin();self.assertEqual(self.rt.named('SetRotation')[-1],('SetRotation',1,True));self.assertEqual(self.w.controls[2].text,'停止旋转') + # Drag: absolute yaw/pitch from where the press started, pitch clamped. + view=self.w.view;self.mouse=(100,100);view.OnMouseLeftButtonDown() + self.mouse=(160,1100);view.OnUpdate() + self.assertEqual(self.rt.named('RotateEyeAroundTarget')[-1],('RotateEyeAroundTarget',1,60.,30.)) + view.OnMouseLeftButtonUp();self.mouse=(0,0);view.OnUpdate() + self.assertEqual(self.rt.named('RotateEyeAroundTarget')[-1],('RotateEyeAroundTarget',1,60.,30.)) + reset();self.assertEqual((self.w.yaw,self.w.pitch,self.w.zoom),(0.,0.,0)) + self.assertEqual(self.rt.named('SetRotation')[-1],('SetRotation',1,True)) # spin survives the reset + self.rt.missing.add(999);self.ns['Preview']('mob',999);self.assertEqual(self.w.note.text,'该怪物没有模型') + zoom_in();self.assertEqual(self.w.zoom,0) + + def test_view_draws_target(self): + self.ns['Preview']('mob',101);self.w.view.pos=(20,70);self.w.view.OnRender() + self.assertEqual(self.rt.named('RenderTexture')[-1],('RenderTexture',1,20,70,320,400)) + + def test_no_leak_on_reopen_and_close(self): + for _ in range(5): + self.ns['Preview']('item',11299);self.assertEqual(self.rt.live,{1}) + self.w.closeButton.event();self.assertEqual(self.rt.live,set());self.assertFalse(self.w.IsShow()) + self.ns['Preview']('mob',101);self.w.OnPressEscapeKey();self.assertEqual(self.rt.live,set()) + self.ns['Preview']('mob',101) + self.game.GameWindow.Close(self.gw) + self.assertIsNone(self.ns['g_window']);self.assertEqual(self.rt.live,set());self.assertEqual(self.rt.calls[-1],('Destroy',)) + self.assertFalse(self.ns['Preview']('mob',101)) + + def test_inventory_and_shop_entries(self): + inv=sys.modules['uiInventory'].InventoryWindow() + inv.SelectItemSlot(3);self.assertEqual(self.inventoryClicks,[3]);self.assertFalse(self.w.IsShow()) + self.keys.add(45) + inv.SelectItemSlot(3);self.assertEqual(self.inventoryClicks,[3]);self.assertTrue(self.w.IsShow()) + inv.SelectItemSlot(4);inv.SelectItemSlot(9);self.assertEqual(self.inventoryClicks,[3,4,9]) # not wearable, empty + dlg=sys.modules['uiShop'].ShopDialog() + dlg.SelectItemSlot(1);self.assertEqual(self.shopClicks,[]);self.assertEqual(self.rt.named('SetArmor')[-1],('SetArmor',1,12049)) + dlg.SelectItemSlot(2);self.assertEqual(self.shopClicks,[2]) + self.keys.clear();dlg.SelectItemSlot(1);self.assertEqual(self.shopClicks,[2,1]) + + def test_wiki_links(self): + wiki=(ROOT/'src/platform/ScriptLib/WikiScript.inc').read_text() + self.assertIn("(u'模型预览',('preview','item',vnum))",wiki);self.assertIn("(u'模型预览',('preview','mob',vnum))",wiki) + self.assertIn("mt_preview.Preview(vnum[1],vnum[2])",wiki) + boot=(ROOT/'src/platform/ScriptLib/PythonBoot.cpp').read_text() + self.assertLess(boot.index('install_script_module("mt_mobile_inventory"'),boot.index('install_script_module("mt_preview"')) + self.assertLess(boot.index('install_script_module("mt_biolog"'),boot.index('install_script_module("mt_preview"')) + + +if __name__=='__main__':unittest.main()