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