From d250c10d04a2aad8d6366895bc6a1f1fbf70f1e1 Mon Sep 17 00:00:00 2001 From: Nemrav <> Date: Sun, 18 Aug 2024 13:48:30 -0300 Subject: [PATCH] Load and manage cursors Also set the normal, busy, and ibeam cursors. --- extension/doc_classes/CursorSingleton.xml | 76 +++ .../src/openvic-extension/register_types.cpp | 9 + .../singletons/CursorSingleton.cpp | 615 ++++++++++++++++++ .../singletons/CursorSingleton.hpp | 78 +++ .../singletons/SoundSingleton.cpp | 15 +- .../openvic-extension/utility/Utilities.hpp | 5 + game/project.godot | 1 + game/src/Game/Autoload/CursorManager.gd | 160 +++++ game/src/Game/GameSession/GameSession.gd | 2 + game/src/Game/GameSession/GameSessionMenu.gd | 1 + game/src/Game/GameStart.gd | 3 +- game/src/Game/LoadingScreen.tscn | 8 + 12 files changed, 962 insertions(+), 11 deletions(-) create mode 100644 extension/doc_classes/CursorSingleton.xml create mode 100644 extension/src/openvic-extension/singletons/CursorSingleton.cpp create mode 100644 extension/src/openvic-extension/singletons/CursorSingleton.hpp create mode 100644 game/src/Game/Autoload/CursorManager.gd diff --git a/extension/doc_classes/CursorSingleton.xml b/extension/doc_classes/CursorSingleton.xml new file mode 100644 index 00000000..b2cc96de --- /dev/null +++ b/extension/doc_classes/CursorSingleton.xml @@ -0,0 +1,76 @@ + + + + + + This singleton handles the dataloading and access to the windows .cur and .ani cursors found in the [code]/cursors[/code] folder. The functionality for setting and animating the cursors is done by the CursorManager gd script. + + + + + + + + + + + Takes the cursor image at [param base_resolution_index] then scales it to [param target_resolution] then saves it to the image as an extra resolution option. + + + + + + + Returns the length of the sequence for the cursor [param cursor_name]. This will be greater than or equal to the number of animation frames. + + + + + + + Returns an array containing how long each frame should last for an animated cursor [param cursor_name]. The size of this array will be the same as the size of the sequence array. + + + + + + + + Returns an array of [ImageTexture] animation frames given a [param cursor_name] and a [param resolution_index]. + + + + + + + + Returns an array of cursor click positions given a [param cursor_name] and [param resolution_index]. + + + + + + + Returns an array of all the image resolutions contained in the cursor. + + + + + + + Returns a list of frame indices used to produce an animation. + + + + + + Loads the cursors from the [code]/cursors[/code] folder. This function must be called before any other cursor related function. + + + + + + A list of cursor file names. + + + diff --git a/extension/src/openvic-extension/register_types.cpp b/extension/src/openvic-extension/register_types.cpp index 91440c0d..9a8a0df4 100644 --- a/extension/src/openvic-extension/register_types.cpp +++ b/extension/src/openvic-extension/register_types.cpp @@ -22,6 +22,7 @@ #include "openvic-extension/classes/MapMesh.hpp" #include "openvic-extension/singletons/AssetManager.hpp" #include "openvic-extension/singletons/Checksum.hpp" +#include "openvic-extension/singletons/CursorSingleton.hpp" #include "openvic-extension/singletons/GameSingleton.hpp" #include "openvic-extension/singletons/LoadLocalisation.hpp" #include "openvic-extension/singletons/MapItemSingleton.hpp" @@ -33,6 +34,7 @@ using namespace godot; using namespace OpenVic; static Checksum* _checksum_singleton = nullptr; +static CursorSingleton* _cursor_singleton = nullptr; static LoadLocalisation* _load_localisation = nullptr; static GameSingleton* _game_singleton = nullptr; static MapItemSingleton* _map_item_singleton = nullptr; @@ -50,6 +52,10 @@ void initialize_openvic_types(ModuleInitializationLevel p_level) { _checksum_singleton = memnew(Checksum); Engine::get_singleton()->register_singleton("Checksum", Checksum::get_singleton()); + ClassDB::register_class(); + _cursor_singleton = memnew(CursorSingleton); + Engine::get_singleton()->register_singleton("CursorSingleton", CursorSingleton::get_singleton()); + ClassDB::register_class(); _load_localisation = memnew(LoadLocalisation); Engine::get_singleton()->register_singleton("LoadLocalisation", LoadLocalisation::get_singleton()); @@ -117,6 +123,9 @@ void uninitialize_openvic_types(ModuleInitializationLevel p_level) { Engine::get_singleton()->unregister_singleton("Checksum"); memdelete(_checksum_singleton); + Engine::get_singleton()->unregister_singleton("CursorSingleton"); + memdelete(_cursor_singleton); + Engine::get_singleton()->unregister_singleton("LoadLocalisation"); memdelete(_load_localisation); diff --git a/extension/src/openvic-extension/singletons/CursorSingleton.cpp b/extension/src/openvic-extension/singletons/CursorSingleton.cpp new file mode 100644 index 00000000..31691196 --- /dev/null +++ b/extension/src/openvic-extension/singletons/CursorSingleton.cpp @@ -0,0 +1,615 @@ +#include "CursorSingleton.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace godot; +using namespace OpenVic; + +void CursorSingleton::_bind_methods() { + OV_BIND_METHOD(CursorSingleton::load_cursors); + OV_BIND_METHOD(CursorSingleton::get_frames, {"cursor_name","resolution_index"}, "normal", 0); + OV_BIND_METHOD(CursorSingleton::get_hotspots, {"cursor_name","resolution_index"}, "normal", 0); + OV_BIND_METHOD(CursorSingleton::get_animation_length, {"cursor_name"}, "normal"); + OV_BIND_METHOD(CursorSingleton::get_display_rates, {"cursor_name"}, "normal"); + OV_BIND_METHOD(CursorSingleton::get_sequence, {"cursor_name"}, "normal"); + OV_BIND_METHOD(CursorSingleton::get_resolutions, {"cursor_name"}, "normal"); + OV_BIND_METHOD(CursorSingleton::generate_resolution, {"cursor_name", "base_resolution_index", "target_resolution"}, "normal", 0, Vector2(64,64)); + OV_BIND_METHOD(CursorSingleton::get_cursor_names); + + ADD_PROPERTY(PropertyInfo( + Variant::ARRAY, + "cursor_names", PROPERTY_HINT_ARRAY_TYPE, + "StringName"), + "", "get_cursor_names"); +} + +CursorSingleton* CursorSingleton::get_singleton() { + return _singleton; +} + +CursorSingleton::CursorSingleton() { + ERR_FAIL_COND(_singleton != nullptr); + _singleton = this; +} + +CursorSingleton::~CursorSingleton() { + ERR_FAIL_COND(_singleton != this); + _singleton = nullptr; +} + +TypedArray CursorSingleton::get_cursor_names() const { + return cursor_names; +} + +TypedArray CursorSingleton::get_frames(StringName const& name, int32_t res_index) const { + const cursor_map_t::const_iterator it = cursors.find(name); + ERR_FAIL_COND_V_MSG(it == cursors.end(), {}, vformat("Cursor \"%s\" not found", name)); + + std::vector> const& images = it->second.images; + ERR_FAIL_INDEX_V_MSG(res_index, images.size(), {}, vformat("Invalid image index for cursor \"%s\": %d", name, res_index)); + + return images[res_index]; +} + +PackedVector2Array CursorSingleton::get_hotspots(StringName const& name, int32_t res_index) const { + const cursor_map_t::const_iterator it = cursors.find(name); + ERR_FAIL_COND_V_MSG(it == cursors.end(), {}, vformat("Cursor \"%s\" not found", name)); + + std::vector const& hotspots = it->second.hotspots; + ERR_FAIL_INDEX_V_MSG(res_index, hotspots.size(), {}, vformat("Invalid hotspot index for cursor \"%s\": %d", name, res_index)); + + return hotspots[res_index]; +} + +int32_t CursorSingleton::get_animation_length(StringName const& name) const { + const cursor_map_t::const_iterator it = cursors.find(name); + ERR_FAIL_COND_V_MSG(it == cursors.end(), {}, vformat("Cursor \"%s\" not found", name)); + + return it->second.animation_length; +} + +PackedVector2Array CursorSingleton::get_resolutions(StringName const& name) const { + const cursor_map_t::const_iterator it = cursors.find(name); + ERR_FAIL_COND_V_MSG(it == cursors.end(), {}, vformat("Cursor \"%s\" not found", name)); + + return it->second.resolutions; +} + +PackedFloat32Array CursorSingleton::get_display_rates(StringName const& name) const { + const cursor_map_t::const_iterator it = cursors.find(name); + ERR_FAIL_COND_V_MSG(it == cursors.end(), {}, vformat("Cursor \"%s\" not found", name)); + + return it->second.display_rates.value_or(PackedFloat32Array()); +} + +PackedInt32Array CursorSingleton::get_sequence(StringName const& name) const { + const cursor_map_t::const_iterator it = cursors.find(name); + ERR_FAIL_COND_V_MSG(it == cursors.end(), {}, vformat("Cursor \"%s\" not found", name)); + + return it->second.sequence.value_or(PackedInt32Array()); +} + +void CursorSingleton::generate_resolution(StringName const& name, int32_t base_res_index, Vector2 target_res) { + cursor_map_t::iterator it = cursors.find(name); + ERR_FAIL_COND_MSG(it == cursors.end(), vformat("Cursor \"%s\" not found", name)); + cursor_asset_t& cursor = it.value(); + + ERR_FAIL_INDEX_MSG( + base_res_index, cursor.images.size(), vformat("Invalid image index for cursor \"%s\": %d", name, base_res_index) + ); + + TypedArray const& images = cursor.images[base_res_index]; + PackedVector2Array const& hotspots = cursor.hotspots[base_res_index]; + const Vector2 resolution = cursor.resolutions[base_res_index]; + TypedArray new_frameset; + PackedVector2Array new_hotspots; + + for (size_t index = 0; index < images.size(); index++) { + Ref const& texture = images[index]; + Ref image; + image.instantiate(); + image->copy_from(texture->get_image()); + image->resize(target_res.x, target_res.y, Image::INTERPOLATE_BILINEAR); + new_frameset.push_back(ImageTexture::create_from_image(image)); + new_hotspots.push_back((hotspots[index] * target_res / resolution).floor()); + } + + cursor.images.push_back(new_frameset); + cursor.hotspots.push_back(new_hotspots); + cursor.resolutions.push_back(target_res); +} + +static constexpr std::string_view cursor_directory = "gfx/cursors"; + +static String _to_define_file_name(String const& path) { + static const String backslash = "\\"; + static const String forwardslash = "/"; + static const String cursor_directory_forwardslash = Utilities::std_to_godot_string(cursor_directory) + forwardslash; + static const String dot = "."; + return path.replace(backslash, forwardslash).get_slice(cursor_directory_forwardslash, 1).get_slice(dot, 0); +} + +Error CursorSingleton::load_cursors() { + GameSingleton const* game_singleton = GameSingleton::get_singleton(); + ERR_FAIL_NULL_V_MSG(game_singleton, FAILED, "Error retrieving GameSingleton"); + + //there is also a png file in the folder we don't want to bother loading + //so don't just load everything in the directory + + //We need to load both ".cur" and ".CUR" files + Dataloader::path_vector_t cursor_files = game_singleton->get_dataloader() + .lookup_files_in_dir_recursive(cursor_directory, ".cur"); + + Dataloader::path_vector_t CURsor_files = game_singleton->get_dataloader() + .lookup_files_in_dir_recursive(cursor_directory, ".CUR"); + cursor_files.insert(std::end(cursor_files),std::begin(CURsor_files),std::end(CURsor_files)); + + Dataloader::path_vector_t animated_cursor_files = game_singleton->get_dataloader() + .lookup_files_in_dir_recursive(cursor_directory, ".ani"); + + if (cursor_files.empty() && animated_cursor_files.empty()){ + Logger::error("failed to load cursors: no files in cursors directory"); + return FAILED; + } + + Error ret = OK; + + for(fs::path const& file_name : cursor_files) { + String file = Utilities::std_to_godot_string(file_name.string()); + StringName name = _to_define_file_name(file); + + if (!_load_cursor_cur(name,file)){ + Logger::error("failed to load normal cursor at path ", file_name); + ret = FAILED; + } + } + + for(fs::path const& file_name : animated_cursor_files) { + String file = Utilities::std_to_godot_string(file_name.string()); + StringName name = _to_define_file_name(file); + + if (!_load_cursor_ani(name,file)){ + Logger::error("failed to load animated cursor at path ", file_name); + ret = FAILED; + } + } + + return ret; +} + +static constexpr int32_t _reverser_lookup[] { + 0x0, 0x8, 0x4, 0xc, 0x2, 0xa, 0x6, 0xe, + 0x1, 0x9, 0x5, 0xd, 0x3, 0xb, 0x7, 0xf +}; +static constexpr int32_t _reverse_bits(int32_t byte, int32_t bits_per_pixel=8) { + int32_t a = _reverser_lookup[(byte & 0b1111)] << 4; + int32_t b = _reverser_lookup[byte >> 4]; + int32_t c = b | a; + return c >> (8-bits_per_pixel); +} + +static constexpr int32_t _rotate_right(int32_t byte, int32_t size=8) { + return ((byte & 0b1) << (size-1)) | (byte >> 1); +} + +static int32_t _load_int_256(Ref const& file) { + int32_t value = file->get_8(); + if (value == 0) value = 256; + return value; +} + +static constexpr int32_t _get_row_start(int32_t x_coord, int32_t y_coord, int32_t bits_per_pixel) { + x_coord *= bits_per_pixel; + int32_t row_count = (x_coord + 31) >> 5; + return row_count * y_coord * 4; // 4 bytes per row * rows down +} + +static constexpr int32_t _select_bits(uint8_t const* data, int32_t row_start, int32_t first_bit, int32_t bit_count) { + int32_t byte_index = first_bit >> 3; + int32_t bit_in_byte_index = first_bit & 0b111; + if (bit_in_byte_index + bit_count > 8) { + Logger::error("Attempted to select bits outside of a byte."); + return 0; + } + int32_t byte = _reverse_bits(*(data+row_start+byte_index)); + int32_t selected = (byte >> bit_in_byte_index) & ((1 << bit_count) - 1); + + //TODO: questionable hack, nothing in the spec suggests we should need to do this + if (bit_count > 1 && selected != 0){ + return _rotate_right(selected,4); + } + return selected; +} + +static constexpr bool _read_AND_mask( + uint8_t const* data, int32_t pixel_x, int32_t pixel_y, int32_t x_dimension, int32_t offset) { + + int32_t row_start = _get_row_start(x_dimension, pixel_y, 1); + int32_t and_bit = _select_bits(data, row_start + offset,pixel_x, 1); + return !and_bit; +} + +static void _pixel_palette_lookup( + PackedByteArray const& data, PackedByteArray& pixel_data, uint32_t i, + PackedByteArray const& palette, int32_t coord_x, int32_t coord_y, int32_t x_dimension, + int32_t offset, bool transparent, int32_t bits_per_pixel) { + + int32_t row_start = _get_row_start(x_dimension, coord_y, bits_per_pixel); + int32_t pixel_bits = _select_bits(data.ptr(), row_start + offset, coord_x*bits_per_pixel, bits_per_pixel); + + if ((pixel_bits+1)*4 > palette.size()){ + Logger::error("attempted to select invalid colour palette entry, ", pixel_bits); + return; + } + + //pixel bits serves as an index into the colour palette. We need to multiply the index by the number of bytes per colour (4) + pixel_data[(i*4) + 0] = palette[pixel_bits*4 + 0]; + pixel_data[(i*4) + 1] = palette[pixel_bits*4 + 1]; + pixel_data[(i*4) + 2] = palette[pixel_bits*4 + 2]; + pixel_data[(i*4) + 3] = 0xFF * transparent; //a +} + +/* +24bit pixel support here is questionable: +the spec (per daubnet) says we should pad bytes to end things on 32bit boundaries +but the singular example of a 24bit cursor found on the internet does things like this. +So emit a warning when trying to load one of these +*/ +static void _read_24bit_pixel( + PackedByteArray const& image_data, PackedByteArray& pixel_data, + int32_t i, int32_t offset, bool opaque) { + + if((i+1)*3 > image_data.size()){ + Logger::error("Pixel ", i, "tried to read from a pixel data array of max size ", pixel_data.size()); + return; + } + + pixel_data[(i*4) + 0] = image_data[offset + (i*3) + 2]; //r + pixel_data[(i*4) + 1] = image_data[offset + (i*3) + 1]; //g + pixel_data[(i*4) + 2] = image_data[offset + (i*3) + 0]; //b + pixel_data[(i*4) + 3] = 0xFF * opaque; //a +} + +static void _read_32bit_pixel( + PackedByteArray const& image_data, PackedByteArray& pixel_data, + int32_t i, int32_t offset, bool opaque) { + + if((i+1)*4 > image_data.size()){ + Logger::error("Pixel ", i, "tried to read from a pixel data array of max size ", pixel_data.size()); + return; + } + + pixel_data[(i*4) + 0] = image_data[offset + (i*4) + 2]; //r + pixel_data[(i*4) + 1] = image_data[offset + (i*4) + 1]; //g + pixel_data[(i*4) + 2] = image_data[offset + (i*4) + 0]; //b + pixel_data[(i*4) + 3] = image_data[offset + (i*4) + 3] * opaque; //a +} + +//used to load a .cur file from a file (could be the a whole .cur file, or a .cur within a .ani file) +static CursorSingleton::image_hotspot_pair_asset_t _load_pair(Ref const& file) { + CursorSingleton::image_hotspot_pair_asset_t pairs = {}; + + //.cur's within .anis won't start of the beginning of the file, so save where they start + int32_t base_offset = file->get_position(); + + //.cur header + int32_t reserved = file->get_16(); + int32_t type = file->get_16(); //1=ico, 2=cur + int32_t images_count = file->get_16(); + + //all the images + for(int32_t i=0; iget_8(); + file->get_8(); //int32_t img_reserved + + Vector2i hotspot = Vector2i(); + hotspot.x = file->get_16(); + hotspot.y = file->get_16(); + + int32_t data_size = std::min(static_cast(file->get_32()), file->get_length() - file->get_position()); + int32_t data_offset = file->get_32(); + + //This image header information is sequential in the data, but the images aren't necessarily + // so save the current position, get the image data and return so we're ready for the next image header + int32_t end_of_image_header = file->get_position(); + + file->seek(data_offset+base_offset); + PackedByteArray const& image_data = file->get_buffer(data_size); + file->seek(end_of_image_header); + + Ref image = Ref(); + image.instantiate(); + + //PNGs are stored in their entirety, so use Godot's internal loader + if (image_data.slice(1,4).get_string_from_ascii() == "PNG") { + image->load_png_from_buffer(image_data); + } + else { //BMP based cursor, have to load this manually + //int32_t dib_header_size = image_data.decode_u32(0); + + //this is the combined sized of the picture and the transparency bitmask + // (ex. 32x32 dimension image becomes 32x64 here) + //Vector2i combined_dimensions = Vector2i(image_data.decode_u32(4),image_data.decode_u32(8)); + //int32_t colour_planes = image_data.decode_u16(12); + int32_t bits_per_pixel = image_data.decode_u16(14); + if (bits_per_pixel <= 8 || bits_per_pixel == 24){ + Logger::warning("Attempting to import ", bits_per_pixel, "bit cursor, this isn't guaranteed to work"); + } + else if (bits_per_pixel != 32){ + Logger::error("Invalid or Unsupported bits per pixel while loading cursor image, bpp: ", bits_per_pixel, "loading blank image instead"); + } + + int32_t size = image_data.decode_u32(20); + Vector2i resolution = Vector2i(image_data.decode_s32(24),image_data.decode_s32(28)); + int32_t palette_size = image_data.decode_u32(32); + + if (palette_size == 0 && bits_per_pixel <= 8){ + palette_size = 1 << bits_per_pixel; + } + //int32_t important_colours = image_data.decode_u32(36); + + //for BMPs with 8 bits per pixel or less, the pixel data is actually a lookup to this table here + PackedByteArray const& palette = image_data.slice(40,40+(4*palette_size)); + + // this is where the image data starts + int32_t offset = 40 + palette_size*4; + + //where the transparency AND mask starts + int32_t mask_offset = offset + _get_row_start(dimensions.x,dimensions.y,bits_per_pixel); + + PackedByteArray pixel_data = PackedByteArray(); + pixel_data.resize(dimensions.x*dimensions.y*4); + pixel_data.fill(255); + + int32_t i=0; + for(int32_t row=0; row < dimensions.y; row++) { + for(int32_t col=0; col < dimensions.x; col++) { + Vector2i coord = Vector2i(col,row); + bool transparent = _read_AND_mask( + image_data.ptr(),coord.x,coord.y,dimensions.x,mask_offset + ); + if (bits_per_pixel <= 8){ + //mostly legacy files, these ones all use a lookup into the colour palette + _pixel_palette_lookup( + image_data, pixel_data, i, palette, coord.x, coord.y, dimensions.x, offset, transparent, bits_per_pixel + ); + }/* + else if (bits_per_pixel == 16) { //TODO + //Unsupported, error + }*/ + else if (bits_per_pixel == 24) { + //Support Questionable, based on 1 example on the internet as opposed to the actual spec + _read_24bit_pixel( + image_data, pixel_data, i, offset, transparent + ); + } + else if (bits_per_pixel == 32) { + //What vic actually uses + _read_32bit_pixel( + image_data, pixel_data, i, offset, transparent + ); + } + i++; + } + } + + image = image->create_from_data(dimensions.x,dimensions.y,false, Image::FORMAT_RGBA8,pixel_data); + //bmp images are stored bottom to top + image->flip_y(); + } + Ref image_texture = Ref(); + image_texture.instantiate(); + + image_texture = image_texture->create_from_image(image); + + if (image_texture.is_null()){ + Logger::error("Image Texture ",Utilities::godot_to_std_string(file->get_path())," was null!"); + } + + pairs.hotspots.push_back(hotspot); + pairs.images.push_back(image_texture); + + } + return pairs; + +} + +bool CursorSingleton::_load_cursor_ani(StringName const& name, String const& path) { + const Ref file = FileAccess::open(path, FileAccess::ModeFlags::READ); + + const Error err = FileAccess::get_open_error(); + ERR_FAIL_COND_V_MSG( + err != OK || file.is_null(), false, vformat("Failed to open ani file: \"%s\"", path) + ); + + //read the RIFF container + Utilities::read_riff_str(file); //riff_id + const uint64_t riff_size = std::min(static_cast(file->get_32()), file->get_length());; + Utilities::read_riff_str(file); //form_type + + //important variables + std::vector> frames_by_resolution; + std::vector hotspots_by_resolution; + + PackedVector2Array resolutions; + PackedFloat32Array display_rates; + PackedInt32Array sequence; + + //ani header variables + int32_t num_frames = 1; + int32_t num_steps = 1; + Vector2i dimensions = Vector2i(1,1); + int32_t bit_count = 1; + int32_t num_planes = 1; //??? + int32_t display_rate = 1; //how long each frame should last + int32_t flags = 0; + bool icon_flag = false; + bool sequence_flag = false; + + + while(file->get_position() < riff_size){ + String id = Utilities::read_riff_str(file); + int32_t size = file->get_32(); + if (id == "LIST"){ + String list_type = Utilities::read_riff_str(file); + } + else if (id == "anih"){ + //hack for some files, there's likely a better way + if (size == 36){ + int32_t headerSize = file->get_32(); + } + num_frames = file->get_32(); + num_steps = file->get_32(); + dimensions = Vector2i(file->get_32(),file->get_32()); + bit_count = file->get_32(); + num_planes = file->get_32(); + display_rate = file->get_32(); + flags = file->get_32(); + icon_flag = flags & 0x1; + sequence_flag = flags & 0x2; + } + else if (id == "icon"){ + + int32_t file_access_offset = file->get_position(); + + image_hotspot_pair_asset_t pair = _load_pair(file); + //basically pushback an array + + //only store the resolutions from one frame + if (resolutions.is_empty()){ + for(int32_t i=0;i images; + images.push_back(pair.images[i]); + hotspots.push_back(pair.hotspots[i]); + frames_by_resolution.push_back(images); + hotspots_by_resolution.push_back(hotspots); + + resolutions.push_back(Vector2(pair.images[i]->get_width(),pair.images[i]->get_height())); + } + + } + else { + if (pair.images.size() != frames_by_resolution.size()){ + Logger::error( + "Malformatted .ani cursor file ", + Utilities::godot_to_std_string(name), + " had inconsistent number of images per cursor" + ); + } + for(int32_t i=0; iseek(file_access_offset + size); + + } + else if (id == "seq "){ + for(int32_t i=0; iget_32()); + } + } + else if (id == "rate"){ + for(int32_t i=0;iget_32()/60.0); + } + } + else { + //Various junk (JUNK, metadata we don't care about, ...) + file->get_buffer(size); + } + //align to even bytes + if ((file->get_position() & 1) != 0){ + file->get_8(); + } + } + + //not all ani files have the sequence and rate chunks, if not, fill out these properties + //manually + if (sequence.is_empty()){ + for(int32_t i=0; i(sequence.size()), + display_rates, + sequence + } + ); + cursor_names.append(name); + + return true; +} + +bool CursorSingleton::_load_cursor_cur(StringName const& name, String const& path) { + const Ref file = FileAccess::open(path, FileAccess::ModeFlags::READ); + const Error err = FileAccess::get_open_error(); + ERR_FAIL_COND_V_MSG( + err != OK || file.is_null(), false, vformat("Failed to open cur file: \"%s\"", path) + ); + + image_hotspot_pair_asset_t pair = _load_pair(file); + + std::vector> frames_by_resolution; + std::vector hotspots_by_resolution; + + PackedVector2Array resolutions; + + for(int32_t i=0;iget_width(),pair.images[i]->get_height())); + + TypedArray frames; + frames.push_back(pair.images[i]); + frames_by_resolution.push_back(frames); + + PackedVector2Array hotspots; + hotspots.push_back(pair.hotspots[i]); + hotspots_by_resolution.push_back(hotspots); + } + + cursors.emplace( + name, + cursor_asset_t { + std::move(hotspots_by_resolution), + std::move(frames_by_resolution), + resolutions, + 1 + } + ); + cursor_names.append(name); + + return true; +} \ No newline at end of file diff --git a/extension/src/openvic-extension/singletons/CursorSingleton.hpp b/extension/src/openvic-extension/singletons/CursorSingleton.hpp new file mode 100644 index 00000000..097e06ef --- /dev/null +++ b/extension/src/openvic-extension/singletons/CursorSingleton.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace OpenVic { + + class CursorSingleton : public godot::Object { + + GDCLASS(CursorSingleton, godot::Object); + static inline CursorSingleton* _singleton = nullptr; + + public: + //An intermediate data type to help with loading cursors. + //The size of images/hotspots arrays corresponds to resolutionsPerCursor. + struct image_hotspot_pair_asset_t { + std::vector hotspots; + std::vector> images; + }; + + private: + //.cur files use all but the last 2 properties, rest are for .ani + struct cursor_asset_t { + std::vector hotspots; + std::vector> images; + godot::PackedVector2Array resolutions; + int32_t animation_length; //1 for static cursors + std::optional display_rates; + std::optional sequence; + }; + + //map of "subfolder/fileName.cur/.ani" -> cursor_asset. Subfolder comes after gfx/cursor + using cursor_map_t = deque_ordered_map; + cursor_map_t cursors; + + godot::TypedArray cursor_names; + + + public: + CursorSingleton(); + ~CursorSingleton(); + static CursorSingleton* get_singleton(); + + protected: + static void _bind_methods(); + + private: + bool _load_cursor_ani(godot::StringName const& name, godot::String const& path); + bool _load_cursor_cur(godot::StringName const& name, godot::String const& path); + + public: + godot::Error load_cursors(); + godot::TypedArray get_cursor_names() const; + + godot::TypedArray get_frames(godot::StringName const& name, int32_t res_index = 0) const; + godot::PackedVector2Array get_hotspots(godot::StringName const& name, int32_t res_index = 0) const; + int32_t get_animation_length(godot::StringName const& name) const; + godot::PackedVector2Array get_resolutions(godot::StringName const& name) const; + godot::PackedFloat32Array get_display_rates(godot::StringName const& name) const; + godot::PackedInt32Array get_sequence(godot::StringName const& name) const; + + void generate_resolution(godot::StringName const& name, int32_t base_res_index, godot::Vector2 target_res); + }; + +} \ No newline at end of file diff --git a/extension/src/openvic-extension/singletons/SoundSingleton.cpp b/extension/src/openvic-extension/singletons/SoundSingleton.cpp index 4ba62f29..f09e3b6d 100644 --- a/extension/src/openvic-extension/singletons/SoundSingleton.cpp +++ b/extension/src/openvic-extension/singletons/SoundSingleton.cpp @@ -295,9 +295,9 @@ Ref SoundSingleton::_load_godot_wav(String const& path) const { sound.instantiate(); //RIFF file header - String riff_id = read_riff_str(file); //RIFF + String riff_id = Utilities::read_riff_str(file); //RIFF int riff_size = std::min(static_cast(file->get_32()), file->get_length()); - String form_type = read_riff_str(file); //WAVE + String form_type = Utilities::read_riff_str(file); //WAVE //ie. 16, 24, 32 bit audio int bits_per_sample = 0; @@ -306,10 +306,10 @@ Ref SoundSingleton::_load_godot_wav(String const& path) const { //RIFF reader while(file->get_position() < riff_size){ - String id = read_riff_str(file); + String id = Utilities::read_riff_str(file); int size = file->get_32(); if(id=="LIST"){ - String list_type = read_riff_str(file); + String list_type = Utilities::read_riff_str(file); } else if(id=="JUNK"){ const PackedByteArray junk = file->get_buffer(size); @@ -341,7 +341,7 @@ Ref SoundSingleton::_load_godot_wav(String const& path) const { //16 byte subformat int subFormat = file->get_16(); - String subFormatString = read_riff_str(file,14); + String subFormatString = Utilities::read_riff_str(file,14); } //set godot properties @@ -399,9 +399,4 @@ Ref SoundSingleton::_load_godot_wav(String const& path) const { sound->set_loop_end(file->get_length()/4); return sound; -} - -//set size if its an info string, otherwise leaving -String SoundSingleton::read_riff_str(Ref const& file, int size) const { - return file->get_buffer(size).get_string_from_ascii(); } \ No newline at end of file diff --git a/extension/src/openvic-extension/utility/Utilities.hpp b/extension/src/openvic-extension/utility/Utilities.hpp index 48be1e03..c1739388 100644 --- a/extension/src/openvic-extension/utility/Utilities.hpp +++ b/extension/src/openvic-extension/utility/Utilities.hpp @@ -8,6 +8,7 @@ #include #include #include +#include "godot_cpp/classes/file_access.hpp" #define ERR(x) ((x) ? OK : FAILED) @@ -21,6 +22,10 @@ namespace OpenVic::Utilities { return godot::String::utf8(str.data(), str.length()); } + _FORCE_INLINE_ godot::String read_riff_str(godot::Ref const& file, int64_t size = 4) { + return file->get_buffer(size).get_string_from_ascii(); + } + godot::String int_to_string_suffixed(int64_t val); godot::String float_to_string_suffixed(float val); diff --git a/game/project.godot b/game/project.godot index 13883395..0ab79b6c 100644 --- a/game/project.godot +++ b/game/project.godot @@ -39,6 +39,7 @@ MusicConductor="*res://src/Game/MusicConductor/MusicConductor.tscn" Keychain="*res://addons/keychain/Keychain.gd" GuiScale="*res://src/Game/Autoload/GuiScale.gd" SaveManager="*res://src/Game/Autoload/SaveManager.gd" +CursorManager="*res://src/Game/Autoload/CursorManager.gd" [display] diff --git a/game/src/Game/Autoload/CursorManager.gd b/game/src/Game/Autoload/CursorManager.gd new file mode 100644 index 00000000..ec4f1a2e --- /dev/null +++ b/game/src/Game/Autoload/CursorManager.gd @@ -0,0 +1,160 @@ +extends Node + +class CompatCursor: + #cursor properties + var cursor_name : StringName + + var resolutions : PackedVector2Array + var frames : Array[ImageTexture] + var hotspots : PackedVector2Array + var is_animated : bool = false + var sequence : PackedInt32Array = [0] + var timings : PackedFloat32Array = [1.0] + + #Cursor state + var current_frame : int = 0 + var time_to_frame : float = 1.0 + + func _init(name_in : StringName) -> void: + cursor_name = name_in + resolutions = CursorSingleton.get_resolutions(cursor_name) + + frames = CursorSingleton.get_frames(cursor_name,0) + hotspots = CursorSingleton.get_hotspots(cursor_name,0) + + is_animated = len(frames) > 1 + if is_animated: + sequence = CursorSingleton.get_sequence(cursor_name) + timings = CursorSingleton.get_display_rates(cursor_name) + time_to_frame = timings[sequence[current_frame]] + + func reset() -> void: + current_frame = 0 + time_to_frame = timings[sequence[0]] + + func set_resolution(resolution : Vector2) -> void: + var index : int = resolutions.find(resolution) + if index != -1: + frames = CursorSingleton.get_frames(cursor_name,index) + return + + #couldnt find it, so generate it based on the highest res available + var highest_res_index : int = 0 + var highest_res_x : int = 0 + for i : int in range(len(resolutions)): + if resolutions[i].x > highest_res_x: + highest_res_x = resolutions[i].x + highest_res_index = i + generate_new_resolution(highest_res_index,resolution) + + resolutions = CursorSingleton.get_resolutions(cursor_name) + frames = CursorSingleton.get_frames(cursor_name,len(resolutions)-1) + hotspots = CursorSingleton.get_hotspots(cursor_name,len(resolutions)-1) + + assert(len(frames) != 0) + + func generate_new_resolution(base_res_index : int, resolution : Vector2) -> void: + # resolution wasn't in among the default, need to generate it ourselves + CursorSingleton.generate_resolution(cursor_name,base_res_index,resolution) + + #only bother with this if the cursor is animated + func _process_cursor(delta : float, shape : Input.CursorShape = Input.CURSOR_ARROW) -> void: + time_to_frame -= delta + if(time_to_frame <= 0): + current_frame = (current_frame + 1) % len(sequence) + time_to_frame += timings[sequence[current_frame]] + set_hardware_cursor(current_frame, shape) + + func set_hardware_cursor(frame : int=0, shape : Input.CursorShape = Input.CURSOR_ARROW) -> void: + var texture : ImageTexture = frames[sequence[frame]] + var hotspot : Vector2 = hotspots[sequence[frame]] + Input.set_custom_mouse_cursor(texture,shape,hotspot) + + +#TODO: This is set on game start, but we probably want this to be a video setting +var preferred_resolution : Vector2 = Vector2(32,32) + +var active_cursor : CompatCursor +var active_shape : Input.CursorShape + +#Shape > Cursor dictionaries +var current_cursors : Dictionary = { + Input.CURSOR_ARROW:null, + Input.CURSOR_BUSY:null, + Input.CURSOR_IBEAM:null +} +var queued_cursors : Dictionary = { + Input.CURSOR_ARROW:null, + Input.CURSOR_BUSY:null, + Input.CURSOR_IBEAM:null +} +var loaded_cursors : Dictionary = {} + +func load_cursors() -> void: + CursorSingleton.load_cursors() + for cursor_name : StringName in CursorSingleton.cursor_names: + var cursor : CompatCursor = CompatCursor.new(cursor_name) + cursor.set_resolution(preferred_resolution) + loaded_cursors[cursor_name] = cursor + + +#Handle queued cursor changes and cursor animations +func _process(delta : float) -> void: + var mouse_shape : Input.CursorShape = Input.get_current_cursor_shape() + + for shape in current_cursors.keys(): + if current_cursors[shape] != queued_cursors[shape]: + current_cursors[shape] = queued_cursors[shape] + if current_cursors[shape] != null: + current_cursors[shape].set_hardware_cursor(0, shape) + else: + Input.set_custom_mouse_cursor(null, shape) + + #The mouse's cursor shape changed (something like we started hovering over text) + # reset the current cursor's frame, then switch the active cursor + if mouse_shape != active_shape: + #Current mouse type changed, need to make sure that if the cursor of this new type + # is animated, we are providing its frames instead of the frames of the previous active cursor + active_shape = mouse_shape + active_cursor = current_cursors.get(active_shape, null) + if active_cursor != null: + active_cursor.reset() + active_cursor.set_hardware_cursor(0, active_shape) + else: + Input.set_custom_mouse_cursor(null, active_shape) + + #if we didnt change cursors and are animated, do an update + elif active_cursor != null and active_cursor.is_animated: + active_cursor._process_cursor(delta,active_shape) + + +func set_preferred_resolution(res_in : Vector2) -> void: + preferred_resolution = res_in + +func set_compat_cursor(cursor_name : StringName, shape : Input.CursorShape = Input.CURSOR_ARROW) -> void: + if cursor_name in loaded_cursors: + var cursor : CompatCursor = loaded_cursors[cursor_name] + cursor.set_resolution(preferred_resolution) + queued_cursors[shape] = cursor + else: + if cursor_name != &"": + push_warning("Cursor name %s is not among loaded cursors" % cursor_name) + queued_cursors[shape] = null + +#NOTE: Each cursor has a corresponding "shape" +# to indicate when window is busy, normal, doing a drag-select, etc. +# You can set this per Control Node under Mouse > Default Cursor Shape + +# set_compat_cursor makes the named vic2 cursor the presently active +# one for the shape it is currently associated with. By default a cursor +# is associated with Input.CURSOR_ARROW, but you can override this with the second +# argument. Use set_compat_cursor as you find it used here in initial_cursor_setup(). + +func initial_cursor_setup() -> void: + set_preferred_resolution(Vector2(32,32)) + load_cursors() + + set_compat_cursor(&"normal") + # When hovered over a control node with mouse shape set to "busy" (ie. loading screens) + # use the pocket watch cursor + set_compat_cursor(&"busy", Input.CURSOR_BUSY) diff --git a/game/src/Game/GameSession/GameSession.gd b/game/src/Game/GameSession/GameSession.gd index 9d07fd6a..1f30cba6 100644 --- a/game/src/Game/GameSession/GameSession.gd +++ b/game/src/Game/GameSession/GameSession.gd @@ -12,6 +12,8 @@ func _ready() -> void: _model_manager.generate_buildings() MusicConductor.generate_playlist() MusicConductor.select_next_song() + # In game, the province selector uses the normal glove cursor. + CursorManager.set_compat_cursor(&"normal", Input.CURSOR_IBEAM) func _process(_delta : float) -> void: GameSingleton.update_clock() diff --git a/game/src/Game/GameSession/GameSessionMenu.gd b/game/src/Game/GameSession/GameSessionMenu.gd index 7db05720..10fb20d8 100644 --- a/game/src/Game/GameSession/GameSessionMenu.gd +++ b/game/src/Game/GameSession/GameSessionMenu.gd @@ -49,6 +49,7 @@ func show_save_dialog_button() -> void: func _on_main_menu_confirmed() -> void: SaveManager.current_session_tag = "" SaveManager.current_save = null + CursorManager.set_compat_cursor(&"", Input.CURSOR_IBEAM) get_tree().change_scene_to_packed(_main_menu_scene) # REQUIREMENTS: diff --git a/game/src/Game/GameStart.gd b/game/src/Game/GameStart.gd index 837e7127..39f56579 100644 --- a/game/src/Game/GameStart.gd +++ b/game/src/Game/GameStart.gd @@ -107,7 +107,8 @@ func _setup_compatibility_mode_paths() -> void: func _load_compatibility_mode() -> void: if GameSingleton.set_compatibility_mode_roots(_compatibility_path_list) != OK: push_error("Errors setting game roots!") - + + CursorManager.initial_cursor_setup() setup_title_theme() if GameSingleton.load_defines_compatibility_mode() != OK: diff --git a/game/src/Game/LoadingScreen.tscn b/game/src/Game/LoadingScreen.tscn index aa84cc36..14d049e7 100644 --- a/game/src/Game/LoadingScreen.tscn +++ b/game/src/Game/LoadingScreen.tscn @@ -66,6 +66,7 @@ anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 +mouse_default_cursor_shape = 5 script = ExtResource("1_b0p3w") progress_bar = NodePath("PanelContainer/MarginContainer/ProgressBar") quote_label = NodePath("PanelContainer/MarginContainer/PanelContainer/QuoteLabel") @@ -78,10 +79,12 @@ anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 +mouse_default_cursor_shape = 5 theme = SubResource("Theme_f5c3e") [node name="MarginContainer" type="MarginContainer" parent="PanelContainer"] layout_mode = 2 +mouse_default_cursor_shape = 5 theme_override_constants/margin_left = 16 theme_override_constants/margin_top = 16 theme_override_constants/margin_right = 16 @@ -90,6 +93,7 @@ theme_override_constants/margin_bottom = 16 [node name="ProgressBar" type="ProgressBar" parent="PanelContainer/MarginContainer"] layout_mode = 2 size_flags_vertical = 8 +mouse_default_cursor_shape = 5 step = 1.0 rounded = true @@ -97,6 +101,7 @@ rounded = true layout_mode = 2 size_flags_horizontal = 4 size_flags_vertical = 0 +mouse_default_cursor_shape = 5 theme_override_styles/panel = SubResource("StyleBoxFlat_yaf7e") [node name="QuoteLabel" type="Label" parent="PanelContainer/MarginContainer/PanelContainer"] @@ -104,6 +109,7 @@ custom_minimum_size = Vector2(700, 80) layout_mode = 2 size_flags_horizontal = 4 size_flags_vertical = 0 +mouse_default_cursor_shape = 5 horizontal_alignment = 1 vertical_alignment = 1 autowrap_mode = 3 @@ -125,6 +131,7 @@ grow_vertical = 2 pivot_offset = Vector2(128, 128) size_flags_horizontal = 4 size_flags_vertical = 4 +mouse_default_cursor_shape = 5 texture = ExtResource("3_avohi") expand_mode = 1 @@ -141,6 +148,7 @@ offset_right = 86.0 offset_bottom = 86.0 grow_horizontal = 2 grow_vertical = 2 +mouse_default_cursor_shape = 5 texture = ExtResource("4_eyeeb") expand_mode = 1