Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/graphics/cursor/cursor_default.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/graphics/cursor/cursor_hover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions assets/graphics/cursor/source/ATTRIBUTION.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
DeadSync asset attributions
===========================

cursor.png
----------
Original artwork: "Cursor icons created by zky.icon - Flaticon"
Source: https://www.flaticon.com/free-icons/cursor

The PNG in this directory is a downsampled and recoloured derivative used
as the in-game mouse cursor. See `scripts/gen_cursors.py` for the
pipeline that produces `assets/graphics/cursor/cursor_default.png` and
`assets/graphics/cursor/cursor_hover.png` from this source.
Binary file added assets/graphics/cursor/source/cursor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions crates/deadsync-core/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ impl Lane {
pub enum InputSource {
Keyboard,
Gamepad,
/// Synthesized from a window-level mouse interaction.
Mouse,
}

#[cfg(test)]
Expand Down
95 changes: 95 additions & 0 deletions scripts/gen_cursors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Build the DeadSync mouse cursor PNGs from the source reference at
`assets/graphics/cursor/source/cursor.png`.

* `cursor_default.png` — the source silhouette with a 1-pixel white halo
so the (dark) cursor stays visible on dark backgrounds.
* `cursor_hover.png` — the same silhouette recoloured to the default
theme's decorative magenta (`#C1006F`) with a darker 1-pixel outline.

Both outputs are 48x48 RGBA. Hotspot is auto-detected at the upper-left
opaque pixel of the downsampled image — the visible tip — and printed at
the end so the value in `src/app/graphics.rs` can be kept in sync.

Run this whenever you tweak the source file or the magenta colour; the
script also drops the freshly-rendered PNGs into
`target/<profile>/assets/graphics/cursor/` so a running game picks them
up without a cargo rebuild.
"""

import os
import shutil
from PIL import Image, ImageFilter

SRC = "assets/graphics/cursor/source/cursor.png"
# Output dimensions of the cursor sprite. 43 = roughly 90% of the
# original 48-pixel size; bump down for a smaller cursor, up for larger.
SIZE = 43
OUT_DIR = "assets/graphics/cursor"

MAGENTA_FILL = (0xC1, 0x00, 0x6F)
MAGENTA_OUTLINE = (0x55, 0x00, 0x30)


def add_halo(img, halo_rgb, thickness=1, alpha=255):
src_alpha = img.split()[-1]
dilated = src_alpha.filter(ImageFilter.MaxFilter(2 * thickness + 1))
if alpha < 255:
dilated = dilated.point(lambda v: int(v * alpha / 255))
halo_layer = Image.new("RGBA", img.size, (*halo_rgb, 0))
halo_layer.putalpha(dilated)
return Image.alpha_composite(halo_layer, img)


def downsample_reference():
src = Image.open(SRC).convert("RGBA")
return src.resize((SIZE, SIZE), Image.LANCZOS)


def recolour(img, rgb):
r, g, b, a = img.split()
r = Image.new("L", img.size, rgb[0])
g = Image.new("L", img.size, rgb[1])
b = Image.new("L", img.size, rgb[2])
return Image.merge("RGBA", (r, g, b, a))


def find_hotspot(img):
"""Return the upper-left-most opaque pixel coordinates as the visible tip."""
alpha = img.split()[-1]
px = alpha.load()
for y in range(img.size[1]):
for x in range(img.size[0]):
if px[x, y] > 128:
return (x, y)
return (0, 0)


def main():
if not os.path.isfile(SRC):
raise SystemExit(f"missing source cursor: {SRC}")
os.makedirs(OUT_DIR, exist_ok=True)
shape = downsample_reference()

default = add_halo(shape, (255, 255, 255), thickness=1)
default.save(f"{OUT_DIR}/cursor_default.png")

hover = recolour(shape, MAGENTA_FILL)
hover = add_halo(hover, MAGENTA_OUTLINE, thickness=1)
hover.save(f"{OUT_DIR}/cursor_hover.png")

hotspot = find_hotspot(default)
print(f"wrote {OUT_DIR}/cursor_default.png")
print(f"wrote {OUT_DIR}/cursor_hover.png")
print(f"hotspot: ({hotspot[0]}, {hotspot[1]}) (use this in src/app/graphics.rs)")

for profile in ("debug", "release"):
t = f"target/{profile}/assets/graphics/cursor"
if not os.path.isdir(t):
continue
for name in ("cursor_default.png", "cursor_hover.png"):
shutil.copyfile(f"{OUT_DIR}/{name}", f"{t}/{name}")
print(f"synced -> {t}/{name}")


if __name__ == "__main__":
main()
43 changes: 43 additions & 0 deletions src/app/graphics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,29 @@ fn request_window_size(window: &Window, backend_type: BackendType, width: u32, h
let _ = window.request_inner_size(size);
}

fn load_custom_cursor_from_asset(
event_loop: &ActiveEventLoop,
rel_path: &str,
hotspot: (u16, u16),
) -> Option<winit::window::CustomCursor> {
let dirs = dirs::app_dirs();
let resolved = dirs.resolve_asset_path(rel_path);
let img = image::open(&resolved)
.map_err(|e| {
log::warn!("custom cursor load failed for {}: {}", resolved.display(), e);
})
.ok()?;
let rgba = img.into_rgba8();
let (width, height) = rgba.dimensions();
let width = u16::try_from(width).ok()?;
let height = u16::try_from(height).ok()?;
let source =
winit::window::CustomCursor::from_rgba(rgba.into_raw(), width, height, hotspot.0, hotspot.1)
.map_err(|e| log::warn!("custom cursor build failed ({}): {}", rel_path, e))
.ok()?;
Some(event_loop.create_custom_cursor(source))
}

fn load_window_icon() -> Option<Icon> {
const WINDOW_ICON_PATHS: [&str; 2] = [
"assets/graphics/icon/icon-256.png",
Expand Down Expand Up @@ -212,6 +235,26 @@ impl App {
// Re-assert the opaque hint so compositors do not apply alpha-based blending.
window.set_transparent(false);
window.set_cursor_visible(!config::get().hide_mouse_cursor);
// Custom cursor theming: load default + hover variants once so we can
// swap them cheaply during pointer dispatch. The hotspot sits at
// the upper-left tip of the navigate shape so clicks register at
// the visual point.
self.cursor_default = load_custom_cursor_from_asset(
event_loop,
"assets/graphics/cursor/cursor_default.png",
(3, 2),
);
self.cursor_hover = load_custom_cursor_from_asset(
event_loop,
"assets/graphics/cursor/cursor_hover.png",
(3, 2),
);
self.cursor_is_hover = false;
if let Some(cursor) = self.cursor_default.clone()
&& !config::get().hide_mouse_cursor
{
window.set_cursor(cursor);
}
let high_dpi = config::get().high_dpi;
let sz = window_render_size(&window, self.backend_type);
self.state.shell.metrics = space::metrics_for_window(sz.width, sz.height);
Expand Down
Loading