From 2ff30f2cd6a4a2cdba3c2ddd74ddc87126a77c3f Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Sat, 11 Jul 2026 12:45:34 +0200 Subject: [PATCH 1/7] experimental tk throws --- .../resonant/psyker/telekinetic_punt.dm | 351 ++++++++++++++++++ tgstation.dme | 1 + 2 files changed, 352 insertions(+) create mode 100644 modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm diff --git a/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm new file mode 100644 index 00000000000000..850f8cc3f8ce5b --- /dev/null +++ b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm @@ -0,0 +1,351 @@ +#define TK_PUNT_CLICK_OVERLAY "psyker_telekinetic_punt_cursor" +#define TK_PUNT_CLICK_NONE 0 +#define TK_PUNT_CLICK_LEFT 1 +#define TK_PUNT_CLICK_MIDDLE 2 + +/datum/power/psyker_power/telekinetic_punt + name = "Telekinetic Punt" + desc = "Quickly punt a nearby object at the target. Activating the power highlights the nearest, strongest object near the cursor, which will be punted automatically at the target when you click the target.\ + \nUnanchored structures deal an additional +10 damage and +1 knockback, and this can wall-stun." + security_record_text = "Subject can wield telekinesis to offensively punt objects at targets" + security_threat = POWER_THREAT_MAJOR + value = 4 + required_powers = list(/datum/power/psyker_power/telekinesis) + action_path = /datum/action/cooldown/power/psyker/telekinetic_punt + +/datum/action/cooldown/power/psyker/telekinetic_punt + name = "Telekinetic Punt" + desc = "Quickly punt a nearby object at the target. Activating the power highlights the nearest, strongest object near the cursor, which will be punted automatically at the target when you click the target.\ + \nUnanchored structures deal an additional +10 damage and +1 knockback, and this can wall-stun." + button_icon = 'icons/mob/actions/actions_spells.dmi' + button_icon_state = "immrod" + click_to_activate = TRUE + unset_after_click = FALSE + target_range = 15 + cooldown_time = 5 + + mental = FALSE // You ain't targeting their mind you're targetting their skull + + /// Minimum damage an item must have to qualify for punt selection. + var/min_damage_to_punt = 5 + /// Maximum distance from the caster that we will consider puntable objects. + var/punt_object_distance = 8 + /// Square radius around the cursor that we scan for candidates. + var/punt_scan_radius = 6 + /// Structures always count as this much base punt damage. + var/structure_punt_damage = 20 + /// Additional damage structures deal on top of their base punt damage. + var/structure_bonus_damage = 0 + /// Base knockback applied on a successful punt impact. + var/base_knockback = 1 + /// Additional knockback granted when the punted object is a structure. + var/structure_bonus_knockback = 0 + + /// Fullscreen cursor tracker used for preview targeting. + var/atom/movable/screen/fullscreen/cursor_catcher/cursor_tracker + /// Last cursor turf we calibrated against. + var/turf/cached_cursor_turf + /// Current object we intend to punt. + var/atom/movable/cached_punt_target + /// Client-only preview image shown beneath the selected object. + var/image/cached_punt_overlay + /// Prevents repeated expensive scans within the same tick. + var/last_preview_tick = -1 + /// Which mouse click variant we are currently resolving. + var/tk_punt_click_type = TK_PUNT_CLICK_NONE + /// Whether the current chambered object is locked in place. + var/lock_chambered_target = FALSE + +/datum/action/cooldown/power/psyker/telekinetic_punt/Grant(mob/granted_to) + . = ..() + last_preview_tick = -1 + +/datum/action/cooldown/power/psyker/telekinetic_punt/Remove(mob/removed_from) + stop_preview(removed_from) + return ..() + +/datum/action/cooldown/power/psyker/telekinetic_punt/set_click_ability(mob/on_who) + . = ..() + if(.) + start_preview(on_who) + return . + +/datum/action/cooldown/power/psyker/telekinetic_punt/unset_click_ability(mob/on_who, refund_cooldown = TRUE) + stop_preview(on_who) + return ..() + +/datum/action/cooldown/power/psyker/telekinetic_punt/InterceptClickOn(mob/living/clicker, params, atom/target) + var/list/modifiers = params2list(params) + if(LAZYACCESS(modifiers, MIDDLE_CLICK)) + tk_punt_click_type = TK_PUNT_CLICK_MIDDLE + target = cached_punt_target || clicker + else + tk_punt_click_type = TK_PUNT_CLICK_LEFT + + . = ..() + if(!.) + tk_punt_click_type = TK_PUNT_CLICK_NONE + return TRUE + +/datum/action/cooldown/power/psyker/telekinetic_punt/process() + if(!owner || owner.click_intercept != src) + stop_preview(owner) + return + + if(last_preview_tick == world.time) + return + last_preview_tick = world.time + + if(cursor_tracker?.mouse_params) + cursor_tracker.calculate_params() + + var/turf/cursor_turf = cursor_tracker?.given_turf + if(!cursor_turf || cursor_turf.z != owner.z) + return + if(lock_chambered_target) + if(cached_punt_target && !is_valid_punt_candidate(owner, cached_punt_target)) + set_cached_punt_target(null) + if(cached_punt_target) + return + else if(cached_punt_target && !is_valid_punt_candidate(owner, cached_punt_target, cursor_turf)) + set_cached_punt_target(null) + if(cached_punt_target && cached_cursor_turf && get_dist(cached_cursor_turf, cursor_turf) <= 1) + return + + refresh_cached_punt_target(owner, cursor_turf) + +/datum/action/cooldown/power/psyker/telekinetic_punt/use_action(mob/living/user, atom/target) + var/click_type = tk_punt_click_type + tk_punt_click_type = TK_PUNT_CLICK_NONE + + if(cursor_tracker?.mouse_params) + cursor_tracker.calculate_params() + + var/turf/cursor_turf = cursor_tracker?.given_turf || cached_cursor_turf || get_turf(target) + if(click_type == TK_PUNT_CLICK_MIDDLE) + if(!is_valid_punt_candidate(user, cached_punt_target, cursor_turf)) + refresh_cached_punt_target(user, cursor_turf) + if(!is_valid_punt_candidate(user, cached_punt_target, cursor_turf)) + user.balloon_alert(user, "nothing chambered!") + return FALSE + lock_chambered_target = !lock_chambered_target + user.balloon_alert(user, lock_chambered_target ? "target locked" : "target unlocked") + return FALSE + + if(!is_valid_punt_candidate(user, cached_punt_target, cursor_turf)) + refresh_cached_punt_target(user, cursor_turf) + + var/atom/movable/punt_target = cached_punt_target + if(!is_valid_punt_candidate(user, punt_target, cursor_turf)) + user.balloon_alert(user, "nothing suitable!") + return FALSE + + var/turf/target_turf = get_turf(target) + if(!target_turf) + return FALSE + + RegisterSignal(punt_target, COMSIG_MOVABLE_IMPACT, PROC_REF(on_punt_impact)) + user.visible_message(span_warning("[user] hurls [punt_target] at [target] with psychic force!")) + playsound(user, 'sound/effects/magic/repulse.ogg', 50, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) + + // Glowey effect + var/filter_id = "psyker_punt_flash" + punt_target.add_filter(filter_id, 1, list(type = "outline", color = POWER_COLOR_PSYKER, size = 2, alpha = 255)) + punt_target.transition_filter(filter_id, list("alpha" = 0), 2 SECONDS) // this actually looks smoother + addtimer(CALLBACK(target, PROC_REF(remove_filter), filter_id), 2 SECONDS) + + var/punt_range = max(get_dist(punt_target, target_turf), 1) + if(!punt_target.safe_throw_at(target_turf, range = punt_range, speed = punt_target.density ? 3 : 4, thrower = null, spin = isitem(punt_target), force = MOVE_FORCE_EXTREMELY_STRONG)) + UnregisterSignal(punt_target, COMSIG_MOVABLE_IMPACT) + user.balloon_alert(user, "can't move that!") + return FALSE + + if(lock_chambered_target) + cached_cursor_turf = cursor_turf + else + cached_cursor_turf = null + set_cached_punt_target(null) + + modify_stress(PSYKER_STRESS_MINOR) // cost + return TRUE + +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/start_preview(mob/on_who) + if(!on_who) + return + if(!cursor_tracker) + cursor_tracker = on_who.overlay_fullscreen(TK_PUNT_CLICK_OVERLAY, /atom/movable/screen/fullscreen/cursor_catcher/telekinetic_punt, 0) + cursor_tracker.assign_to_mob(on_who) + cached_cursor_turf = null + last_preview_tick = -1 + lock_chambered_target = FALSE + tk_punt_click_type = TK_PUNT_CLICK_NONE + set_cached_punt_target(null) + START_PROCESSING(SSfastprocess, src) + +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/stop_preview(mob/on_who) + STOP_PROCESSING(SSfastprocess, src) + if(on_who) + on_who.clear_fullscreen(TK_PUNT_CLICK_OVERLAY) + cursor_tracker = null + cached_cursor_turf = null + last_preview_tick = -1 + lock_chambered_target = FALSE + tk_punt_click_type = TK_PUNT_CLICK_NONE + set_cached_punt_target(null) + +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/refresh_cached_punt_target(mob/living/user, turf/cursor_turf) + if(!user || !cursor_turf) + set_cached_punt_target(null) + return + var/atom/movable/best_target = find_best_punt_target(user, cursor_turf) + set_cached_punt_target(best_target) + if(best_target) + cached_cursor_turf = cursor_turf + else + cached_cursor_turf = null + +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/set_cached_punt_target(atom/movable/new_target) + if(owner?.client && cached_punt_overlay) + owner.client.images -= cached_punt_overlay + cached_punt_overlay = null + if(cached_punt_target != new_target && lock_chambered_target) + lock_chambered_target = FALSE + cached_punt_target = new_target + if(!new_target || !owner?.client) + return + + cached_punt_overlay = image('icons/effects/effects.dmi', new_target, "launchpad_pull") + cached_punt_overlay.layer = new_target.layer - 0.1 + owner.client.images += cached_punt_overlay + +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/find_best_punt_target(mob/living/user, turf/cursor_turf) + var/atom/movable/best_target + var/best_score = -1 + var/best_distance = INFINITY + + for(var/scan_x in (cursor_turf.x - punt_scan_radius) to (cursor_turf.x + punt_scan_radius)) + for(var/scan_y in (cursor_turf.y - punt_scan_radius) to (cursor_turf.y + punt_scan_radius)) + var/turf/scan_turf = locate(scan_x, scan_y, cursor_turf.z) + if(!scan_turf) + continue + + for(var/obj/item/item_target in scan_turf) + var/item_damage = get_punt_damage(item_target) + if(item_damage < min_damage_to_punt) + continue + if(!is_valid_punt_candidate(user, item_target, cursor_turf)) + continue + var/item_distance = get_dist(cursor_turf, item_target) + var/item_score = get_effective_punt_score(item_damage, item_distance) + if(item_score > best_score || (item_score == best_score && item_distance < best_distance)) + best_target = item_target + best_score = item_score + best_distance = item_distance + + for(var/obj/structure/structure_target in scan_turf) + var/structure_damage = get_punt_damage(structure_target) + if(structure_damage < min_damage_to_punt) + continue + if(!is_valid_punt_candidate(user, structure_target, cursor_turf)) + continue + var/structure_distance = get_dist(cursor_turf, structure_target) + var/structure_score = get_effective_punt_score(structure_damage, structure_distance) + if(structure_score > best_score || (structure_score == best_score && structure_distance < best_distance)) + best_target = structure_target + best_score = structure_score + best_distance = structure_distance + + return best_target + +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/is_valid_punt_candidate(mob/living/user, atom/movable/candidate, turf/cursor_turf) + if(!candidate || QDELETED(candidate)) + return FALSE + if(!isturf(candidate.loc)) + return FALSE + if(candidate.anchored) + return FALSE + if(candidate.move_resist >= MOVE_FORCE_EXTREMELY_STRONG) + return FALSE + if(get_dist(user, candidate) > punt_object_distance) + return FALSE + if(!(candidate in view(user))) + return FALSE + if(cursor_turf && !(cursor_turf in view(candidate))) + return FALSE + if(isitem(candidate)) + var/obj/item/item_candidate = candidate + if(item_candidate.item_flags & ABSTRACT) + return FALSE + return get_punt_damage(item_candidate) >= min_damage_to_punt + if(isstructure(candidate)) + return get_punt_damage(candidate) >= min_damage_to_punt + return FALSE + +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/get_punt_damage(atom/movable/candidate) + if(isitem(candidate)) + var/obj/item/item_candidate = candidate + return max(item_candidate.throwforce, item_candidate.force) + if(isstructure(candidate)) + return structure_punt_damage + return 0 + +/// Treats the effective damage as less based on distance given these have a chance to miss/be obstructed, causing bias to closer objects. +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/get_effective_punt_score(base_damage, distance) + return max(base_damage * (1 - (0.1 * distance)), 0) + +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/on_punt_impact(atom/movable/source, atom/hit_atom, datum/thrownthing/thrownthing, caught) + SIGNAL_HANDLER + UnregisterSignal(source, COMSIG_MOVABLE_IMPACT) + + var/knockback = base_knockback + var/mob/thrower = owner + if(thrownthing?.get_thrower()) + thrower = thrownthing.get_thrower() + + if(caught) + return + + if(isitem(source)) + if(isliving(hit_atom) && knockback > 0) + var/mob/living/living_target = hit_atom + var/throw_dir = get_dir(source, living_target) + if(!throw_dir && thrower) + throw_dir = get_dir(thrower, living_target) + if(throw_dir) + var/atom/throw_target = get_edge_target_turf(living_target, throw_dir) + living_target.throw_at(throw_target, knockback, 2, thrower) + return + + var/damage = get_punt_damage(source) + if(isstructure(source)) + damage += structure_bonus_damage + knockback += structure_bonus_knockback + + if(isliving(hit_atom)) + var/mob/living/living_target = hit_atom + + living_target.apply_damage(damage, BRUTE) + if(knockback > 0) + var/throw_dir = get_dir(source, living_target) + if(!throw_dir && thrower) + throw_dir = get_dir(thrower, living_target) + if(throw_dir) + var/atom/throw_target = get_edge_target_turf(living_target, throw_dir) + living_target.throw_at(throw_target, knockback, 2, thrower) + playsound(living_target, 'sound/items/lead_pipe_hit.ogg', 75, TRUE, SILENCED_SOUND_EXTRARANGE) + living_target.log_message("was hit by a telekinetically punted [source] from [thrower] for [damage] damage.", LOG_VICTIM) + thrower?.log_message("telekinetically punted [source] into [living_target] for [damage] damage.", LOG_ATTACK) + else if(hit_atom.uses_integrity) + hit_atom.take_damage(damage, BRUTE, MELEE) + +/atom/movable/screen/fullscreen/cursor_catcher/telekinetic_punt + +/atom/movable/screen/fullscreen/cursor_catcher/telekinetic_punt/Click(location, control, params) + if(usr == owner) + calculate_params() + given_turf?.Click(location, control, params) + +#undef TK_PUNT_CLICK_OVERLAY +#undef TK_PUNT_CLICK_NONE +#undef TK_PUNT_CLICK_LEFT +#undef TK_PUNT_CLICK_MIDDLE diff --git a/tgstation.dme b/tgstation.dme index 212253e407290b..af21eefd8714c3 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7614,6 +7614,7 @@ #include "modular_doppler\modular_powers\code\powers\resonant\psyker\premonition.dm" #include "modular_doppler\modular_powers\code\powers\resonant\psyker\scrying.dm" #include "modular_doppler\modular_powers\code\powers\resonant\psyker\telekinesis.dm" +#include "modular_doppler\modular_powers\code\powers\resonant\psyker\telekinetic_punt.dm" #include "modular_doppler\modular_powers\code\powers\resonant\psyker\telepathy.dm" #include "modular_doppler\modular_powers\code\powers\resonant\psyker\telepathy_area.dm" #include "modular_doppler\modular_powers\code\powers\resonant\psyker\ward_mind.dm" From 5c61a0e459286466fd4d7cca392ca9b12785880b Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Sat, 11 Jul 2026 17:21:15 +0200 Subject: [PATCH 2/7] Lots of optimizations surrounding telekinetic punt --- .../resonant/psyker/telekinetic_punt.dm | 606 ++++++++++++------ 1 file changed, 425 insertions(+), 181 deletions(-) diff --git a/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm index 850f8cc3f8ce5b..e9aed5e936cf1f 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm @@ -3,10 +3,17 @@ #define TK_PUNT_CLICK_LEFT 1 #define TK_PUNT_CLICK_MIDDLE 2 +/* + So, power that launches the best nearby object at people. This has a lot of nuance, especially with my insistance on being able to preview which item you will throw. + This means we on the fly need to compute the best object, before its thrown, and in a way that does not kill the server's processing. + The datum/telekentic_punt_preview below the action is the best I could do there. When moving your mouse over a tile, it gets the best nearby object to be thrown towards that tile. You can lock objects with middle click too. +*/ + /datum/power/psyker_power/telekinetic_punt name = "Telekinetic Punt" desc = "Quickly punt a nearby object at the target. Activating the power highlights the nearest, strongest object near the cursor, which will be punted automatically at the target when you click the target.\ - \nUnanchored structures deal an additional +10 damage and +1 knockback, and this can wall-stun." + \nUnanchored structures deal an additional +10 damage and +1 knockback, and this can wall-stun.\ + \nMiddle-click to lock onto an object, ensuring you will always punt with it." security_record_text = "Subject can wield telekinesis to offensively punt objects at targets" security_threat = POWER_THREAT_MAJOR value = 4 @@ -16,7 +23,8 @@ /datum/action/cooldown/power/psyker/telekinetic_punt name = "Telekinetic Punt" desc = "Quickly punt a nearby object at the target. Activating the power highlights the nearest, strongest object near the cursor, which will be punted automatically at the target when you click the target.\ - \nUnanchored structures deal an additional +10 damage and +1 knockback, and this can wall-stun." + \nUnanchored structures deal an additional +10 damage and +1 knockback, and this can wall-stun.\ + \nMiddle-click to lock onto an object, ensuring you will always punt with it." button_icon = 'icons/mob/actions/actions_spells.dmi' button_icon_state = "immrod" click_to_activate = TRUE @@ -40,311 +48,547 @@ var/base_knockback = 1 /// Additional knockback granted when the punted object is a structure. var/structure_bonus_knockback = 0 - - /// Fullscreen cursor tracker used for preview targeting. - var/atom/movable/screen/fullscreen/cursor_catcher/cursor_tracker - /// Last cursor turf we calibrated against. - var/turf/cached_cursor_turf - /// Current object we intend to punt. - var/atom/movable/cached_punt_target - /// Client-only preview image shown beneath the selected object. - var/image/cached_punt_overlay - /// Prevents repeated expensive scans within the same tick. - var/last_preview_tick = -1 + /// Damage thresholds that marks objects as strong enough that we don't need to look further away for better, causing the expanding search area to stop expanding and only use its area for determening the best object. + var/strong_object_threshold = 20 /// Which mouse click variant we are currently resolving. var/tk_punt_click_type = TK_PUNT_CLICK_NONE - /// Whether the current chambered object is locked in place. - var/lock_chambered_target = FALSE - -/datum/action/cooldown/power/psyker/telekinetic_punt/Grant(mob/granted_to) - . = ..() - last_preview_tick = -1 + /// Active preview session while the power is click-armed. + var/datum/telekinetic_punt_preview/preview_datum +/// Cleans up any active preview session when the action is removed from its owner. /datum/action/cooldown/power/psyker/telekinetic_punt/Remove(mob/removed_from) - stop_preview(removed_from) + QDEL_NULL(preview_datum) return ..() +/// Arms the action for click targeting and creates a fresh preview session. /datum/action/cooldown/power/psyker/telekinetic_punt/set_click_ability(mob/on_who) . = ..() if(.) - start_preview(on_who) + QDEL_NULL(preview_datum) + preview_datum = new(src, on_who) return . +/// Disarms the action for click targeting and destroys the preview session. /datum/action/cooldown/power/psyker/telekinetic_punt/unset_click_ability(mob/on_who, refund_cooldown = TRUE) - stop_preview(on_who) + QDEL_NULL(preview_datum) return ..() +/// Intercepts clicks so middle click toggles locking while left click resolves the punt normally. /datum/action/cooldown/power/psyker/telekinetic_punt/InterceptClickOn(mob/living/clicker, params, atom/target) var/list/modifiers = params2list(params) if(LAZYACCESS(modifiers, MIDDLE_CLICK)) tk_punt_click_type = TK_PUNT_CLICK_MIDDLE - target = cached_punt_target || clicker + target = preview_datum?.cached_punt_target || clicker else tk_punt_click_type = TK_PUNT_CLICK_LEFT - . = ..() if(!.) tk_punt_click_type = TK_PUNT_CLICK_NONE return TRUE -/datum/action/cooldown/power/psyker/telekinetic_punt/process() - if(!owner || owner.click_intercept != src) - stop_preview(owner) - return - - if(last_preview_tick == world.time) - return - last_preview_tick = world.time - - if(cursor_tracker?.mouse_params) - cursor_tracker.calculate_params() - - var/turf/cursor_turf = cursor_tracker?.given_turf - if(!cursor_turf || cursor_turf.z != owner.z) - return - if(lock_chambered_target) - if(cached_punt_target && !is_valid_punt_candidate(owner, cached_punt_target)) - set_cached_punt_target(null) - if(cached_punt_target) - return - else if(cached_punt_target && !is_valid_punt_candidate(owner, cached_punt_target, cursor_turf)) - set_cached_punt_target(null) - if(cached_punt_target && cached_cursor_turf && get_dist(cached_cursor_turf, cursor_turf) <= 1) - return - - refresh_cached_punt_target(owner, cursor_turf) - +/// Resolves locking or throws the currently chambered object at the clicked target turf. /datum/action/cooldown/power/psyker/telekinetic_punt/use_action(mob/living/user, atom/target) var/click_type = tk_punt_click_type tk_punt_click_type = TK_PUNT_CLICK_NONE - if(cursor_tracker?.mouse_params) - cursor_tracker.calculate_params() + // Datum gets you your targets so if this is happening something's gone wroooong. + if(!preview_datum || QDELETED(preview_datum)) + user.balloon_alert(user, "power fizzles!") + return FALSE + + // Finds the turf that you currently are hovering over. + var/turf/cursor_turf = preview_datum.get_cursor_turf(target) + + /// MIDDLE CLICK LOGIC (Locking). - var/turf/cursor_turf = cursor_tracker?.given_turf || cached_cursor_turf || get_turf(target) if(click_type == TK_PUNT_CLICK_MIDDLE) - if(!is_valid_punt_candidate(user, cached_punt_target, cursor_turf)) - refresh_cached_punt_target(user, cursor_turf) - if(!is_valid_punt_candidate(user, cached_punt_target, cursor_turf)) + // If we are NOT locked onto a specific object and the current object does not pass as valid, we try to find a new valid target to lock o nanyway. + if(!preview_datum.lock_chambered_target && !is_valid_punt_candidate(user, preview_datum.cached_punt_target, cursor_turf)) + preview_datum.refresh_cached_punt_target(cursor_turf) + // If we ARE locked onto a specific object... + if(preview_datum.lock_chambered_target) + /// ... And the object has become invalid, we clear it out. + if(!is_valid_punt_candidate(user, preview_datum.cached_punt_target)) + preview_datum.set_cached_punt_target(null) + user.balloon_alert(user, "object invalid!") + return FALSE + /// If we fail to lock on after the first proc, nothing will happen. + else if(!is_valid_punt_candidate(user, preview_datum.cached_punt_target, cursor_turf)) user.balloon_alert(user, "nothing chambered!") return FALSE - lock_chambered_target = !lock_chambered_target - user.balloon_alert(user, lock_chambered_target ? "target locked" : "target unlocked") + /// Toggles the lock on/off + preview_datum.toggle_lock() + user.balloon_alert(user, preview_datum.lock_chambered_target ? "target locked" : "target unlocked") return FALSE - if(!is_valid_punt_candidate(user, cached_punt_target, cursor_turf)) - refresh_cached_punt_target(user, cursor_turf) + /// LEFT CLICK/RIGHT CLICK LOGIC (Punting). - var/atom/movable/punt_target = cached_punt_target - if(!is_valid_punt_candidate(user, punt_target, cursor_turf)) + var/atom/movable/punt_target = preview_datum.cached_punt_target + // Checks if we are allowed to punt the target + if(!is_valid_punt_candidate(user, punt_target)) user.balloon_alert(user, "nothing suitable!") return FALSE - var/turf/target_turf = get_turf(target) + // Gets our destination target + var/turf/target_turf = get_punt_target_turf(user, punt_target, target) if(!target_turf) return FALSE + // Gets a signaler so we can pass extra damage off on hit. RegisterSignal(punt_target, COMSIG_MOVABLE_IMPACT, PROC_REF(on_punt_impact)) - user.visible_message(span_warning("[user] hurls [punt_target] at [target] with psychic force!")) - playsound(user, 'sound/effects/magic/repulse.ogg', 50, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) - // Glowey effect + // Visual/Audio feedback + user.visible_message(span_warning("[user] gestures towards [punt_target], punting it with telekinetic force!")) + playsound(user, 'sound/effects/magic/repulse.ogg', 50, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) + // This outline marks the punt as telekinetic and is faded out immediately after launch. var/filter_id = "psyker_punt_flash" punt_target.add_filter(filter_id, 1, list(type = "outline", color = POWER_COLOR_PSYKER, size = 2, alpha = 255)) - punt_target.transition_filter(filter_id, list("alpha" = 0), 2 SECONDS) // this actually looks smoother - addtimer(CALLBACK(target, PROC_REF(remove_filter), filter_id), 2 SECONDS) + // Gets the range and attempts to PUNT the object at it. var/punt_range = max(get_dist(punt_target, target_turf), 1) - if(!punt_target.safe_throw_at(target_turf, range = punt_range, speed = punt_target.density ? 3 : 4, thrower = null, spin = isitem(punt_target), force = MOVE_FORCE_EXTREMELY_STRONG)) + if(!punt_target.safe_throw_at(target_turf, range = punt_range, speed = punt_target.density ? 3 : 4, thrower = user, spin = isitem(punt_target), force = MOVE_FORCE_EXTREMELY_STRONG)) UnregisterSignal(punt_target, COMSIG_MOVABLE_IMPACT) user.balloon_alert(user, "can't move that!") + fade_filter(punt_target, filter_id) return FALSE - if(lock_chambered_target) - cached_cursor_turf = cursor_turf - else - cached_cursor_turf = null - set_cached_punt_target(null) + // Clean-up of effects + preview + fade_filter(punt_target, filter_id) + preview_datum.clear_after_throw(cursor_turf) + apply_punt_throw_effect(user) - modify_stress(PSYKER_STRESS_MINOR) // cost + modify_stress(PSYKER_STRESS_MINOR * 1.5) // cost return TRUE -/datum/action/cooldown/power/psyker/telekinetic_punt/proc/start_preview(mob/on_who) - if(!on_who) +/// Fades and removes the telekinetic outline filter from a thrown object. +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/fade_filter(atom/movable/punt_target, filter_id) + if(!punt_target) return - if(!cursor_tracker) - cursor_tracker = on_who.overlay_fullscreen(TK_PUNT_CLICK_OVERLAY, /atom/movable/screen/fullscreen/cursor_catcher/telekinetic_punt, 0) - cursor_tracker.assign_to_mob(on_who) - cached_cursor_turf = null - last_preview_tick = -1 - lock_chambered_target = FALSE - tk_punt_click_type = TK_PUNT_CLICK_NONE - set_cached_punt_target(null) - START_PROCESSING(SSfastprocess, src) - -/datum/action/cooldown/power/psyker/telekinetic_punt/proc/stop_preview(mob/on_who) - STOP_PROCESSING(SSfastprocess, src) - if(on_who) - on_who.clear_fullscreen(TK_PUNT_CLICK_OVERLAY) - cursor_tracker = null - cached_cursor_turf = null - last_preview_tick = -1 - lock_chambered_target = FALSE - tk_punt_click_type = TK_PUNT_CLICK_NONE - set_cached_punt_target(null) + punt_target.transition_filter(filter_id, list("alpha" = 0), 2 SECONDS) + addtimer(CALLBACK(punt_target, PROC_REF(remove_filter), filter_id), 2 SECONDS) -/datum/action/cooldown/power/psyker/telekinetic_punt/proc/refresh_cached_punt_target(mob/living/user, turf/cursor_turf) - if(!user || !cursor_turf) - set_cached_punt_target(null) +/// Applies a short-lived psychic sparkle overlay to the psyker after a successful punt. +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/apply_punt_throw_effect(mob/living/user) + if(!user) return - var/atom/movable/best_target = find_best_punt_target(user, cursor_turf) - set_cached_punt_target(best_target) - if(best_target) - cached_cursor_turf = cursor_turf - else - cached_cursor_turf = null - -/datum/action/cooldown/power/psyker/telekinetic_punt/proc/set_cached_punt_target(atom/movable/new_target) - if(owner?.client && cached_punt_overlay) - owner.client.images -= cached_punt_overlay - cached_punt_overlay = null - if(cached_punt_target != new_target && lock_chambered_target) - lock_chambered_target = FALSE - cached_punt_target = new_target - if(!new_target || !owner?.client) + var/mutable_appearance/player_icon = mutable_appearance( + icon = 'icons/effects/effects.dmi', + icon_state = "purplesparkles", + layer = user.layer - 0.1, + appearance_flags = RESET_ALPHA|RESET_COLOR|RESET_TRANSFORM|KEEP_APART + ) + user.add_overlay(player_icon) + addtimer(CALLBACK(src, PROC_REF(remove_punt_throw_effect), user, player_icon), 1.5 SECONDS) + +/// Removes the temporary psychic sparkle overlay from the psyker once its linger timer completes. +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/remove_punt_throw_effect(mob/living/user, mutable_appearance/player_icon) + if(!user || !player_icon) return - - cached_punt_overlay = image('icons/effects/effects.dmi', new_target, "launchpad_pull") - cached_punt_overlay.layer = new_target.layer - 0.1 - owner.client.images += cached_punt_overlay - -/datum/action/cooldown/power/psyker/telekinetic_punt/proc/find_best_punt_target(mob/living/user, turf/cursor_turf) - var/atom/movable/best_target - var/best_score = -1 - var/best_distance = INFINITY - - for(var/scan_x in (cursor_turf.x - punt_scan_radius) to (cursor_turf.x + punt_scan_radius)) - for(var/scan_y in (cursor_turf.y - punt_scan_radius) to (cursor_turf.y + punt_scan_radius)) - var/turf/scan_turf = locate(scan_x, scan_y, cursor_turf.z) - if(!scan_turf) - continue - - for(var/obj/item/item_target in scan_turf) - var/item_damage = get_punt_damage(item_target) - if(item_damage < min_damage_to_punt) - continue - if(!is_valid_punt_candidate(user, item_target, cursor_turf)) - continue - var/item_distance = get_dist(cursor_turf, item_target) - var/item_score = get_effective_punt_score(item_damage, item_distance) - if(item_score > best_score || (item_score == best_score && item_distance < best_distance)) - best_target = item_target - best_score = item_score - best_distance = item_distance - - for(var/obj/structure/structure_target in scan_turf) - var/structure_damage = get_punt_damage(structure_target) - if(structure_damage < min_damage_to_punt) - continue - if(!is_valid_punt_candidate(user, structure_target, cursor_turf)) - continue - var/structure_distance = get_dist(cursor_turf, structure_target) - var/structure_score = get_effective_punt_score(structure_damage, structure_distance) - if(structure_score > best_score || (structure_score == best_score && structure_distance < best_distance)) - best_target = structure_target - best_score = structure_score - best_distance = structure_distance - - return best_target - + user.cut_overlay(player_icon) + +/// Resolves the turf we actually throw at, clipping locked throws to the furthest reachable turf instead of replacing the object. +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/get_punt_target_turf(mob/living/user, atom/movable/punt_target, atom/desired_target) + var/turf/desired_turf = get_turf(desired_target) + if(!desired_turf) + return null + if(!preview_datum?.lock_chambered_target) + return desired_turf + // If the locked object can directly see the clicked turf, just use it. + if(desired_turf in view(punt_target)) + return desired_turf + + // Otherwise, clip the throw along that line to the furthest turf inside the object's current view. + var/list/view_size = getviewsize(user?.client?.view || world.view) + var/view_range = round(max((view_size[1] - 1) / 2, (view_size[2] - 1) / 2)) + if(view_range <= 0) + return null + return get_ranged_target_turf_direct(punt_target, desired_turf, view_range) + +/// Validates whether an atom can currently be chambered and punted by this action. /datum/action/cooldown/power/psyker/telekinetic_punt/proc/is_valid_punt_candidate(mob/living/user, atom/movable/candidate, turf/cursor_turf) + // Object does not exist. if(!candidate || QDELETED(candidate)) return FALSE + // Object is not in the world if(!isturf(candidate.loc)) return FALSE + // Object is anchored (can't move) if(candidate.anchored) return FALSE + // Object is too dense (or thicc as we call it nowadays). if(candidate.move_resist >= MOVE_FORCE_EXTREMELY_STRONG) return FALSE + // Object is too far away. if(get_dist(user, candidate) > punt_object_distance) return FALSE + // Object can't be seen by us. if(!(candidate in view(user))) return FALSE + // Object isn't within line-of-sight of the hovered turf if(cursor_turf && !(cursor_turf in view(candidate))) return FALSE + + // Sweet, it's an item. Lets caclulate the punt damage. if(isitem(candidate)) var/obj/item/item_candidate = candidate if(item_candidate.item_flags & ABSTRACT) return FALSE return get_punt_damage(item_candidate) >= min_damage_to_punt + // It's a structure? Calculate the punt damage. if(isstructure(candidate)) return get_punt_damage(candidate) >= min_damage_to_punt + + // Whatever you are, we don't want you return FALSE +/// Returns the effective damage value used when ranking puntable items and structures. /datum/action/cooldown/power/psyker/telekinetic_punt/proc/get_punt_damage(atom/movable/candidate) + // Item specific calculation if(isitem(candidate)) var/obj/item/item_candidate = candidate return max(item_candidate.throwforce, item_candidate.force) + // Structures default to 20 cause structures normally do 10 + 1 knockback on impact, and we boost that by another 10. + // This usually makes them the desired object. if(isstructure(candidate)) return structure_punt_damage return 0 +/// Returns a scoring distance which mildly penalizes diagonal offsets to prefer straighter, less obstruction-prone picks. +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/get_effective_punt_distance(turf/cursor_turf, atom/movable/candidate) + var/base_distance = get_dist(cursor_turf, candidate) + if(!cursor_turf || !candidate) + return base_distance + + var/delta_x = abs(cursor_turf.x - candidate.x) + var/delta_y = abs(cursor_turf.y - candidate.y) + // Diagonals get an extra tax so equally-close cardinal objects win the selection more often. + if(delta_x && delta_y) + return base_distance + 0.5 + return base_distance + /// Treats the effective damage as less based on distance given these have a chance to miss/be obstructed, causing bias to closer objects. /datum/action/cooldown/power/psyker/telekinetic_punt/proc/get_effective_punt_score(base_damage, distance) return max(base_damage * (1 - (0.1 * distance)), 0) +/// Applies additional knockback and manual structure damage when the thrown object impacts something. /datum/action/cooldown/power/psyker/telekinetic_punt/proc/on_punt_impact(atom/movable/source, atom/hit_atom, datum/thrownthing/thrownthing, caught) SIGNAL_HANDLER UnregisterSignal(source, COMSIG_MOVABLE_IMPACT) - - var/knockback = base_knockback - var/mob/thrower = owner - if(thrownthing?.get_thrower()) - thrower = thrownthing.get_thrower() - + // Nothing happens when caught! if(caught) return - + // Nothing happens if its an item! if(isitem(source)) - if(isliving(hit_atom) && knockback > 0) - var/mob/living/living_target = hit_atom - var/throw_dir = get_dir(source, living_target) - if(!throw_dir && thrower) - throw_dir = get_dir(thrower, living_target) - if(throw_dir) - var/atom/throw_target = get_edge_target_turf(living_target, throw_dir) - living_target.throw_at(throw_target, knockback, 2, thrower) return + var/knockback = base_knockback var/damage = get_punt_damage(source) + + // Structures do bonus damage and knockback if(isstructure(source)) damage += structure_bonus_damage knockback += structure_bonus_knockback + // When hitting a living mob (usually our target) if(isliving(hit_atom)) var/mob/living/living_target = hit_atom living_target.apply_damage(damage, BRUTE) if(knockback > 0) var/throw_dir = get_dir(source, living_target) - if(!throw_dir && thrower) - throw_dir = get_dir(thrower, living_target) + if(!throw_dir && owner) + throw_dir = get_dir(owner, living_target) if(throw_dir) var/atom/throw_target = get_edge_target_turf(living_target, throw_dir) - living_target.throw_at(throw_target, knockback, 2, thrower) - playsound(living_target, 'sound/items/lead_pipe_hit.ogg', 75, TRUE, SILENCED_SOUND_EXTRARANGE) - living_target.log_message("was hit by a telekinetically punted [source] from [thrower] for [damage] damage.", LOG_VICTIM) - thrower?.log_message("telekinetically punted [source] into [living_target] for [damage] damage.", LOG_ATTACK) + living_target.throw_at(throw_target, knockback, 2, owner) + + playsound(living_target, 'sound/items/lead_pipe_hit.ogg', 75, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) // Punt does it, so does ours. Its just funny. + living_target.log_message("was hit by a telekinetically punted [source] from [owner] for [damage] damage.", LOG_VICTIM) + owner?.log_message("telekinetically punted [source] into [living_target] for [damage] damage.", LOG_ATTACK) + // If it has integrity aka structures, damage it instead. else if(hit_atom.uses_integrity) hit_atom.take_damage(damage, BRUTE, MELEE) +// Decleration of cursor catcher /atom/movable/screen/fullscreen/cursor_catcher/telekinetic_punt +/// Forwards clicks through the fullscreen catcher to the turf currently under the mouse. /atom/movable/screen/fullscreen/cursor_catcher/telekinetic_punt/Click(location, control, params) if(usr == owner) calculate_params() given_turf?.Click(location, control, params) +/* + This datum largely handles selecting an appropriate item to yeet. If its on a tile that it hasn't processed yet, it will attempt to process it, getting all valid targets within the action's range. + Once it gets a target, it will select it as cached_punt_target. + The targetting system has a few specific biases for gameplay: + - Items are treated as dealing 10% less damage for every turf they are away from the moused-over turf, up to the maximum range of Telekinetic Punt. + - Diagonals count as 0.5 turfs further away so that it biases towards horizontal/vertical targets. + - If there is a valid target on the mouse-over turf, it will prefer that. + - If an object whose force equals the action's strong_object_threshold is found, it will stop broadening its search area and only consider objects currently within it, as we have at least one really good item. + To try and optimize these massive scans, we only scan once per turf. In addition, lock mode will prevent any repeated checks. + +*/ +/datum/telekinetic_punt_preview + /// The action that owns this preview session. + var/datum/action/cooldown/power/psyker/telekinetic_punt/source_action + /// The mob currently using the click-intercept preview. + var/mob/living/owner + /// Fullscreen cursor tracker used for preview targeting. + var/atom/movable/screen/fullscreen/cursor_catcher/cursor_tracker + /// Last cursor turf we calibrated against. + var/turf/cached_cursor_turf + /// Current object we intend to punt. + var/atom/movable/cached_punt_target + /// Client-only preview image shown beneath the selected object. + var/image/cached_punt_overlay + /// Prevents repeated expensive scans within the same tick. + var/last_preview_tick = -1 + /// Whether the current chambered object is locked in place. + var/lock_chambered_target = FALSE + +/// Creates a preview session, attaches the fullscreen cursor catcher, and begins fast processing. +/datum/telekinetic_punt_preview/New(datum/action/cooldown/power/psyker/telekinetic_punt/new_source_action, mob/living/new_owner) + . = ..() + source_action = new_source_action + owner = new_owner + if(!source_action || !owner) + qdel(src) + return + cursor_tracker = owner.overlay_fullscreen(TK_PUNT_CLICK_OVERLAY, /atom/movable/screen/fullscreen/cursor_catcher/telekinetic_punt, 0) + cursor_tracker?.assign_to_mob(owner) + START_PROCESSING(SSfastprocess, src) + +/// Tears down the fullscreen overlay, preview image, and back-reference from the owning action. +/datum/telekinetic_punt_preview/Destroy(force) + STOP_PROCESSING(SSfastprocess, src) + if(owner) + owner.clear_fullscreen(TK_PUNT_CLICK_OVERLAY) + set_cached_punt_target(null) + if(source_action?.preview_datum == src) + source_action.preview_datum = null + cursor_tracker = null + cached_cursor_turf = null + cached_punt_target = null + owner = null + source_action = null + return ..() + +/// Re-evaluates the chambered object while the power is armed and the cursor moves around. +/datum/telekinetic_punt_preview/process() + if(!source_action || !owner || owner.click_intercept != source_action) + qdel(src) + return + + if(last_preview_tick == world.time) + return + last_preview_tick = world.time + + if(cursor_tracker?.mouse_params) + cursor_tracker.calculate_params() + + var/turf/cursor_turf = cursor_tracker?.given_turf + if(!cursor_turf || cursor_turf.z != owner.z) + return + if(lock_chambered_target) + if(cached_punt_target && !source_action.is_valid_punt_candidate(owner, cached_punt_target)) + set_cached_punt_target(null) + if(cached_punt_target) + return + else if(cached_punt_target && !source_action.is_valid_punt_candidate(owner, cached_punt_target, cursor_turf)) + set_cached_punt_target(null) + // We only skip rescanning if the cursor stayed on the exact same turf; adjacent movement now recalculates. + if(cached_punt_target && cached_cursor_turf && cached_cursor_turf == cursor_turf) + return + + refresh_cached_punt_target(cursor_turf) + +/// Returns the current cursor turf, falling back to the cached turf or the clicked target's turf if needed. +/datum/telekinetic_punt_preview/proc/get_cursor_turf(atom/fallback_target) + if(cursor_tracker?.mouse_params) + cursor_tracker.calculate_params() + return cursor_tracker?.given_turf || cached_cursor_turf || get_turf(fallback_target) + +/// Rebuilds the chambered target for the current cursor turf and updates the cache accordingly. +/datum/telekinetic_punt_preview/proc/refresh_cached_punt_target(turf/cursor_turf) + if(!owner || !cursor_turf) + set_cached_punt_target(null) + return + var/atom/movable/best_target = find_best_punt_target(cursor_turf) + set_cached_punt_target(best_target) + if(best_target) + cached_cursor_turf = cursor_turf + else + cached_cursor_turf = null + +/// Finds the best punt candidate near the cursor using exact-turf priority and distance-weighted scoring. +/datum/telekinetic_punt_preview/proc/find_best_punt_target(turf/cursor_turf) + if(!owner || !source_action || !cursor_turf) + return null + + // If we are hovering a valid target directly, prefer that turf over any nearby "stronger" find. + var/atom/movable/exact_turf_target = find_best_punt_target_on_hovered_turf(cursor_turf) + if(exact_turf_target) + return exact_turf_target + + var/atom/movable/best_target + var/best_score = -1 + var/best_distance = INFINITY + + for(var/radius in 1 to source_action.punt_scan_radius) + // Determines if we have found an object that's at or above strong_object_threshold, stopping us from expanding the area. + var/found_terminal_candidate = FALSE + + for(var/scan_x in (cursor_turf.x - radius) to (cursor_turf.x + radius)) + var/list/top_edge_result = evaluate_scan_turf(cursor_turf, locate(scan_x, cursor_turf.y + radius, cursor_turf.z), best_target, best_score, best_distance) + best_target = top_edge_result["best_target"] + best_score = top_edge_result["best_score"] + best_distance = top_edge_result["best_distance"] + found_terminal_candidate = top_edge_result["found_terminal_candidate"] || found_terminal_candidate + if(radius > 0) + var/list/bottom_edge_result = evaluate_scan_turf(cursor_turf, locate(scan_x, cursor_turf.y - radius, cursor_turf.z), best_target, best_score, best_distance) + best_target = bottom_edge_result["best_target"] + best_score = bottom_edge_result["best_score"] + best_distance = bottom_edge_result["best_distance"] + found_terminal_candidate = bottom_edge_result["found_terminal_candidate"] || found_terminal_candidate + + for(var/scan_y in (cursor_turf.y - radius + 1) to (cursor_turf.y + radius - 1)) + var/list/right_edge_result = evaluate_scan_turf(cursor_turf, locate(cursor_turf.x + radius, scan_y, cursor_turf.z), best_target, best_score, best_distance) + best_target = right_edge_result["best_target"] + best_score = right_edge_result["best_score"] + best_distance = right_edge_result["best_distance"] + found_terminal_candidate = right_edge_result["found_terminal_candidate"] || found_terminal_candidate + if(radius > 0) + var/list/left_edge_result = evaluate_scan_turf(cursor_turf, locate(cursor_turf.x - radius, scan_y, cursor_turf.z), best_target, best_score, best_distance) + best_target = left_edge_result["best_target"] + best_score = left_edge_result["best_score"] + best_distance = left_edge_result["best_distance"] + found_terminal_candidate = left_edge_result["found_terminal_candidate"] || found_terminal_candidate + + // Once a sufficiently strong nearby object exists, finish this ring and stop expanding outward. + if(found_terminal_candidate) + break + + return best_target + +/// Evaluates a single turf in the expanding ring scan, returning updated best-candidate state and whether the stop threshold was reached. +/datum/telekinetic_punt_preview/proc/evaluate_scan_turf(turf/cursor_turf, turf/scan_turf, atom/movable/best_target, best_score, best_distance) + if(!scan_turf) + return list( + "best_target" = best_target, + "best_score" = best_score, + "best_distance" = best_distance, + "found_terminal_candidate" = FALSE, + ) + + var/found_terminal_candidate = FALSE + + for(var/obj/item/item_target in scan_turf) + var/item_damage = source_action.get_punt_damage(item_target) + if(item_damage < source_action.min_damage_to_punt) + continue + if(!source_action.is_valid_punt_candidate(owner, item_target, cursor_turf)) + continue + // Indicates we have found an object that deals at least 20 damage, meaning we already have a good enough canidate and don't need to search further. + if(item_damage >= source_action.strong_object_threshold) + found_terminal_candidate = TRUE + var/item_distance = source_action.get_effective_punt_distance(cursor_turf, item_target) + var/item_score = source_action.get_effective_punt_score(item_damage, item_distance) + if(item_score > best_score || (item_score == best_score && item_distance < best_distance)) + best_target = item_target + best_score = item_score + best_distance = item_distance + + for(var/obj/structure/structure_target in scan_turf) + var/structure_damage = source_action.get_punt_damage(structure_target) + if(structure_damage < source_action.min_damage_to_punt) + continue + if(!source_action.is_valid_punt_candidate(owner, structure_target, cursor_turf)) + continue + if(structure_damage >= source_action.strong_object_threshold) + found_terminal_candidate = TRUE + var/structure_distance = source_action.get_effective_punt_distance(cursor_turf, structure_target) + var/structure_score = source_action.get_effective_punt_score(structure_damage, structure_distance) + if(structure_score > best_score || (structure_score == best_score && structure_distance < best_distance)) + best_target = structure_target + best_score = structure_score + best_distance = structure_distance + + return list( + "best_target" = best_target, + "best_score" = best_score, + "best_distance" = best_distance, + "found_terminal_candidate" = found_terminal_candidate, + ) + +/// Picks the strongest valid target on the exact hovered turf before any wider scan is considered. +/datum/telekinetic_punt_preview/proc/find_best_punt_target_on_hovered_turf(turf/cursor_turf) + if(!owner || !source_action || !cursor_turf) + return null + + var/atom/movable/best_target + var/best_score = -1 + + // Hovering over tiles with objects will scan those tiles for targets and if there's at least one canidate, it will always use only those canidates. + for(var/obj/item/item_target in cursor_turf) + var/item_damage = source_action.get_punt_damage(item_target) + if(item_damage < source_action.min_damage_to_punt) + continue + if(!source_action.is_valid_punt_candidate(owner, item_target, cursor_turf)) + continue + var/item_score = source_action.get_effective_punt_score(item_damage, 0) + if(item_score > best_score) + best_target = item_target + best_score = item_score + + // Hovering over tiles with structures will scan those tiles for targets and if there's at least one canidate, it will always use only those canidates. + for(var/obj/structure/structure_target in cursor_turf) + var/structure_damage = source_action.get_punt_damage(structure_target) + if(structure_damage < source_action.min_damage_to_punt) + continue + if(!source_action.is_valid_punt_candidate(owner, structure_target, cursor_turf)) + continue + var/structure_score = source_action.get_effective_punt_score(structure_damage, 0) + if(structure_score > best_score) + best_target = structure_target + best_score = structure_score + + return best_target + +/// Replaces the chambered target and maintains the owner-only marker image beneath it. +/datum/telekinetic_punt_preview/proc/set_cached_punt_target(atom/movable/new_target) + if(owner?.client && cached_punt_overlay) + owner.client.images -= cached_punt_overlay + cached_punt_overlay = null + // Locks are tied to a specific chambered object; changing the object clears the lock. + if(cached_punt_target != new_target && lock_chambered_target) + lock_chambered_target = FALSE + cached_punt_target = new_target + if(!new_target || !owner?.client) + return + + cached_punt_overlay = image('icons/effects/effects.dmi', new_target, "launchpad_pull") + cached_punt_overlay.layer = new_target.layer - 0.1 + // Reset transform/color so the marker follows the object without inheriting thrown spin or source coloration. + cached_punt_overlay.appearance_flags = RESET_TRANSFORM | KEEP_APART + // The source sprite is authored red, so we use a saturation-override filter instead of a simple tint. + owner.client.images += cached_punt_overlay + +/// Toggles the lock on the current chambered target if one exists. +/datum/telekinetic_punt_preview/proc/toggle_lock() + if(!cached_punt_target) + return FALSE + lock_chambered_target = !lock_chambered_target + return TRUE + +/// Clears the chamber after a throw unless the user explicitly locked the current object. +/datum/telekinetic_punt_preview/proc/clear_after_throw(turf/cursor_turf) + if(lock_chambered_target) + cached_cursor_turf = cursor_turf + return + cached_cursor_turf = null + set_cached_punt_target(null) + #undef TK_PUNT_CLICK_OVERLAY #undef TK_PUNT_CLICK_NONE #undef TK_PUNT_CLICK_LEFT From a2f0cc6fc039986b10d909fa2504d2ad0d699fec Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Tue, 14 Jul 2026 15:29:44 +0200 Subject: [PATCH 3/7] Final touches on punt. Vending machines only have a 5% chance to flip when thrown, powers actions now let you properly unset clicks even if the ability is on cooldown. --- code/datums/actions/cooldown_action.dm | 6 +- code/modules/vending/vendor/throwing.dm | 2 +- .../resonant/psyker/telekinetic_punt.dm | 224 +++++++++++------- .../modular_powers/code/powers_action.dm | 15 +- .../icons/powers/actions_icons.dmi | Bin 3167 -> 3336 bytes 5 files changed, 148 insertions(+), 99 deletions(-) diff --git a/code/datums/actions/cooldown_action.dm b/code/datums/actions/cooldown_action.dm index 9f1bd18f29daaa..26a2b0b8fad516 100644 --- a/code/datums/actions/cooldown_action.dm +++ b/code/datums/actions/cooldown_action.dm @@ -234,11 +234,7 @@ return InterceptClickOn(user, null, target) var/datum/action/cooldown/already_set = user.click_intercept - if(already_set == src) - // if we clicked ourself and we're already set, unset and return - return unset_click_ability(user, refund_cooldown = TRUE) - - else if(istype(already_set)) + if(istype(already_set)) // if we have an active set already, unset it before we set our's already_set.unset_click_ability(user, refund_cooldown = TRUE) diff --git a/code/modules/vending/vendor/throwing.dm b/code/modules/vending/vendor/throwing.dm index 0b1cbbd0a178af..b84c542e726658 100644 --- a/code/modules/vending/vendor/throwing.dm +++ b/code/modules/vending/vendor/throwing.dm @@ -44,7 +44,7 @@ ///Crush the mob that the vending machine got thrown at /obj/machinery/vending/throw_impact(atom/hit_atom, datum/thrownthing/throwingdatum) - if(isliving(hit_atom)) + if(isliving(hit_atom) && prob(5)) // DOPPLER MODULAR EDIT: Makes vending machines toppling chance-based, because several powers can now throw vending machines at roundstart which is just too strong. Previously: if(isliving(hit_atom)) tilt(fatty=hit_atom) return ..() diff --git a/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm index e9aed5e936cf1f..cb32fa962d5f0d 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm @@ -1,8 +1,4 @@ #define TK_PUNT_CLICK_OVERLAY "psyker_telekinetic_punt_cursor" -#define TK_PUNT_CLICK_NONE 0 -#define TK_PUNT_CLICK_LEFT 1 -#define TK_PUNT_CLICK_MIDDLE 2 - /* So, power that launches the best nearby object at people. This has a lot of nuance, especially with my insistance on being able to preview which item you will throw. This means we on the fly need to compute the best object, before its thrown, and in a way that does not kill the server's processing. @@ -25,12 +21,13 @@ desc = "Quickly punt a nearby object at the target. Activating the power highlights the nearest, strongest object near the cursor, which will be punted automatically at the target when you click the target.\ \nUnanchored structures deal an additional +10 damage and +1 knockback, and this can wall-stun.\ \nMiddle-click to lock onto an object, ensuring you will always punt with it." - button_icon = 'icons/mob/actions/actions_spells.dmi' - button_icon_state = "immrod" + button_icon = 'modular_doppler/modular_powers/icons/powers/actions_icons.dmi' + button_icon_state = "telekinetic_punt" // not a good spriter so this needs a better sprite at one point click_to_activate = TRUE unset_after_click = FALSE target_range = 15 - cooldown_time = 5 + cooldown_time = 15 + click_cd_override = CLICK_CD_ACTIVATE_ABILITY / 2 // I normally do not change this but it largely has to do with being able to lock with middle-mouse is affected by this. This feels smoother, in a way. mental = FALSE // You ain't targeting their mind you're targetting their skull @@ -50,8 +47,8 @@ var/structure_bonus_knockback = 0 /// Damage thresholds that marks objects as strong enough that we don't need to look further away for better, causing the expanding search area to stop expanding and only use its area for determening the best object. var/strong_object_threshold = 20 - /// Which mouse click variant we are currently resolving. - var/tk_punt_click_type = TK_PUNT_CLICK_NONE + /// How much stress we generate upon use? + var/stress_cost = PSYKER_STRESS_MINOR * 1.5 /// Active preview session while the power is click-armed. var/datum/telekinetic_punt_preview/preview_datum @@ -77,20 +74,13 @@ /datum/action/cooldown/power/psyker/telekinetic_punt/InterceptClickOn(mob/living/clicker, params, atom/target) var/list/modifiers = params2list(params) if(LAZYACCESS(modifiers, MIDDLE_CLICK)) - tk_punt_click_type = TK_PUNT_CLICK_MIDDLE - target = preview_datum?.cached_punt_target || clicker - else - tk_punt_click_type = TK_PUNT_CLICK_LEFT + handle_middle_click(clicker, target) + return TRUE . = ..() - if(!.) - tk_punt_click_type = TK_PUNT_CLICK_NONE return TRUE -/// Resolves locking or throws the currently chambered object at the clicked target turf. -/datum/action/cooldown/power/psyker/telekinetic_punt/use_action(mob/living/user, atom/target) - var/click_type = tk_punt_click_type - tk_punt_click_type = TK_PUNT_CLICK_NONE - +/// Handles middle-click target locking separately from the cooldowned punt activation so lock control still works while the action is cooling down. +/datum/action/cooldown/power/psyker/telekinetic_punt/proc/handle_middle_click(mob/living/user, atom/target) // Datum gets you your targets so if this is happening something's gone wroooong. if(!preview_datum || QDELETED(preview_datum)) user.balloon_alert(user, "power fizzles!") @@ -99,28 +89,35 @@ // Finds the turf that you currently are hovering over. var/turf/cursor_turf = preview_datum.get_cursor_turf(target) - /// MIDDLE CLICK LOGIC (Locking). - - if(click_type == TK_PUNT_CLICK_MIDDLE) - // If we are NOT locked onto a specific object and the current object does not pass as valid, we try to find a new valid target to lock o nanyway. - if(!preview_datum.lock_chambered_target && !is_valid_punt_candidate(user, preview_datum.cached_punt_target, cursor_turf)) - preview_datum.refresh_cached_punt_target(cursor_turf) - // If we ARE locked onto a specific object... - if(preview_datum.lock_chambered_target) - /// ... And the object has become invalid, we clear it out. - if(!is_valid_punt_candidate(user, preview_datum.cached_punt_target)) - preview_datum.set_cached_punt_target(null) - user.balloon_alert(user, "object invalid!") - return FALSE - /// If we fail to lock on after the first proc, nothing will happen. - else if(!is_valid_punt_candidate(user, preview_datum.cached_punt_target, cursor_turf)) - user.balloon_alert(user, "nothing chambered!") + // If we are NOT locked onto a specific object and the current object does not pass as valid, we try to find a new valid target to lock o nanyway. + if(!preview_datum.lock_chambered_target && !is_valid_punt_candidate(user, preview_datum.cached_punt_target, cursor_turf)) + preview_datum.refresh_cached_punt_target(cursor_turf) + // If we ARE locked onto a specific object... + if(preview_datum.lock_chambered_target) + /// ... And the object has become invalid, we clear it out. + if(!is_valid_punt_candidate(user, preview_datum.cached_punt_target)) + preview_datum.set_cached_punt_target(null) + user.balloon_alert(user, "object invalid!") return FALSE - /// Toggles the lock on/off - preview_datum.toggle_lock() - user.balloon_alert(user, preview_datum.lock_chambered_target ? "target locked" : "target unlocked") + /// If we fail to lock on after the first proc, nothing will happen. + else if(!is_valid_punt_candidate(user, preview_datum.cached_punt_target, cursor_turf)) + user.balloon_alert(user, "nothing chambered!") + return FALSE + /// Toggles the lock on/off + preview_datum.toggle_lock() + user.balloon_alert(user, preview_datum.lock_chambered_target ? "target locked" : "target unlocked") + return TRUE + +/// Throws the currently chambered object at the clicked target turf. +/datum/action/cooldown/power/psyker/telekinetic_punt/use_action(mob/living/user, atom/target) + // Datum gets you your targets so if this is happening something's gone wroooong. + if(!preview_datum || QDELETED(preview_datum)) + user.balloon_alert(user, "power fizzles!") return FALSE + // Finds the turf that you currently are hovering over. + var/turf/cursor_turf = preview_datum.get_cursor_turf(target) + /// LEFT CLICK/RIGHT CLICK LOGIC (Punting). var/atom/movable/punt_target = preview_datum.cached_punt_target @@ -157,15 +154,15 @@ preview_datum.clear_after_throw(cursor_turf) apply_punt_throw_effect(user) - modify_stress(PSYKER_STRESS_MINOR * 1.5) // cost + modify_stress(stress_cost) // cost return TRUE /// Fades and removes the telekinetic outline filter from a thrown object. /datum/action/cooldown/power/psyker/telekinetic_punt/proc/fade_filter(atom/movable/punt_target, filter_id) if(!punt_target) return - punt_target.transition_filter(filter_id, list("alpha" = 0), 2 SECONDS) - addtimer(CALLBACK(punt_target, PROC_REF(remove_filter), filter_id), 2 SECONDS) + punt_target.transition_filter(filter_id, list("alpha" = 0), 1.5 SECONDS) + addtimer(CALLBACK(punt_target, PROC_REF(remove_filter), filter_id), 1.5 SECONDS) /// Applies a short-lived psychic sparkle overlay to the psyker after a successful punt. /datum/action/cooldown/power/psyker/telekinetic_punt/proc/apply_punt_throw_effect(mob/living/user) @@ -234,22 +231,24 @@ if(item_candidate.item_flags & ABSTRACT) return FALSE return get_punt_damage(item_candidate) >= min_damage_to_punt - // It's a structure? Calculate the punt damage. + // It's a structure or machine? Calculate the punt damage. if(isstructure(candidate)) return get_punt_damage(candidate) >= min_damage_to_punt + if(ismachinery(candidate)) + return get_punt_damage(candidate) >= min_damage_to_punt // Whatever you are, we don't want you return FALSE -/// Returns the effective damage value used when ranking puntable items and structures. +/// Returns the effective damage value used when ranking puntable items and structure-like objects. /datum/action/cooldown/power/psyker/telekinetic_punt/proc/get_punt_damage(atom/movable/candidate) // Item specific calculation if(isitem(candidate)) var/obj/item/item_candidate = candidate return max(item_candidate.throwforce, item_candidate.force) - // Structures default to 20 cause structures normally do 10 + 1 knockback on impact, and we boost that by another 10. - // This usually makes them the desired object. - if(isstructure(candidate)) + // Structures and machinery default to 20 cause structures normally do 10 + 1 knockback on impact, and we boost that by another 10. + // This usually makes them desireable to punt. + if(isstructure(candidate) || ismachinery(candidate)) return structure_punt_damage return 0 @@ -270,7 +269,7 @@ /datum/action/cooldown/power/psyker/telekinetic_punt/proc/get_effective_punt_score(base_damage, distance) return max(base_damage * (1 - (0.1 * distance)), 0) -/// Applies additional knockback and manual structure damage when the thrown object impacts something. +/// Applies additional knockback and manual heavy-object damage when the thrown object impacts something. /datum/action/cooldown/power/psyker/telekinetic_punt/proc/on_punt_impact(atom/movable/source, atom/hit_atom, datum/thrownthing/thrownthing, caught) SIGNAL_HANDLER UnregisterSignal(source, COMSIG_MOVABLE_IMPACT) @@ -284,8 +283,8 @@ var/knockback = base_knockback var/damage = get_punt_damage(source) - // Structures do bonus damage and knockback - if(isstructure(source)) + // Structures and machinery do bonus damage and knockback. + if(isstructure(source) || ismachinery(source)) damage += structure_bonus_damage knockback += structure_bonus_knockback @@ -439,6 +438,16 @@ // Determines if we have found an object that's at or above strong_object_threshold, stopping us from expanding the area. var/found_terminal_candidate = FALSE + /* scan_x is responsible for the top and bottom sides, scan_y is the left and right sides of the expanding radius. + Just to illustrate: a 5x5 square radius will be handled like so, where C is the cursor, X is a turf scanned by scan_x and Y for scan_y, and . indicates no scan (because it already scanned that turf). + X X X X X + Y . . . Y + Y . C . Y + Y . . . Y + X X X X X + */ + + // Scans the top and bottom sides of the radius square for(var/scan_x in (cursor_turf.x - radius) to (cursor_turf.x + radius)) var/list/top_edge_result = evaluate_scan_turf(cursor_turf, locate(scan_x, cursor_turf.y + radius, cursor_turf.z), best_target, best_score, best_distance) best_target = top_edge_result["best_target"] @@ -452,6 +461,7 @@ best_distance = bottom_edge_result["best_distance"] found_terminal_candidate = bottom_edge_result["found_terminal_candidate"] || found_terminal_candidate + // Scans the left and right sides of the radius square for(var/scan_y in (cursor_turf.y - radius + 1) to (cursor_turf.y + radius - 1)) var/list/right_edge_result = evaluate_scan_turf(cursor_turf, locate(cursor_turf.x + radius, scan_y, cursor_turf.z), best_target, best_score, best_distance) best_target = right_edge_result["best_target"] @@ -483,36 +493,43 @@ var/found_terminal_candidate = FALSE + /// Scans all obj/item on the turf for(var/obj/item/item_target in scan_turf) - var/item_damage = source_action.get_punt_damage(item_target) - if(item_damage < source_action.min_damage_to_punt) - continue - if(!source_action.is_valid_punt_candidate(owner, item_target, cursor_turf)) + var/list/item_evaluation = evaluate_punt_candidate(item_target, cursor_turf, best_score, best_distance) + if(!item_evaluation) continue // Indicates we have found an object that deals at least 20 damage, meaning we already have a good enough canidate and don't need to search further. - if(item_damage >= source_action.strong_object_threshold) - found_terminal_candidate = TRUE - var/item_distance = source_action.get_effective_punt_distance(cursor_turf, item_target) - var/item_score = source_action.get_effective_punt_score(item_damage, item_distance) - if(item_score > best_score || (item_score == best_score && item_distance < best_distance)) - best_target = item_target - best_score = item_score - best_distance = item_distance + found_terminal_candidate = item_evaluation["found_terminal_candidate"] || found_terminal_candidate + if(!item_evaluation["improved_best_target"]) + continue + best_target = item_target + best_score = item_evaluation["best_score"] + best_distance = item_evaluation["best_distance"] + /// Scans all obj/structure on the turf for(var/obj/structure/structure_target in scan_turf) - var/structure_damage = source_action.get_punt_damage(structure_target) - if(structure_damage < source_action.min_damage_to_punt) + var/list/structure_evaluation = evaluate_punt_candidate(structure_target, cursor_turf, best_score, best_distance) + if(!structure_evaluation) + continue + // Indicates we have found an object that deals at least 20 damage, meaning we already have a good enough canidate and don't need to search further. + found_terminal_candidate = structure_evaluation["found_terminal_candidate"] || found_terminal_candidate + if(!structure_evaluation["improved_best_target"]) + continue + best_target = structure_target + best_score = structure_evaluation["best_score"] + best_distance = structure_evaluation["best_distance"] + + /// Scans all obj/machinery on the turf + for(var/obj/machinery/machinery_target in scan_turf) + var/list/machinery_evaluation = evaluate_punt_candidate(machinery_target, cursor_turf, best_score, best_distance) + if(!machinery_evaluation) continue - if(!source_action.is_valid_punt_candidate(owner, structure_target, cursor_turf)) + found_terminal_candidate = machinery_evaluation["found_terminal_candidate"] || found_terminal_candidate + if(!machinery_evaluation["improved_best_target"]) continue - if(structure_damage >= source_action.strong_object_threshold) - found_terminal_candidate = TRUE - var/structure_distance = source_action.get_effective_punt_distance(cursor_turf, structure_target) - var/structure_score = source_action.get_effective_punt_score(structure_damage, structure_distance) - if(structure_score > best_score || (structure_score == best_score && structure_distance < best_distance)) - best_target = structure_target - best_score = structure_score - best_distance = structure_distance + best_target = machinery_target + best_score = machinery_evaluation["best_score"] + best_distance = machinery_evaluation["best_distance"] return list( "best_target" = best_target, @@ -521,6 +538,24 @@ "found_terminal_candidate" = found_terminal_candidate, ) +/// Scores a single punt candidate against the current best result and reports whether it improves selection or reaches the strong-object stop threshold. +/datum/telekinetic_punt_preview/proc/evaluate_punt_candidate(atom/movable/candidate, turf/cursor_turf, best_score, best_distance = INFINITY, hovered_turf_only = FALSE) + var/candidate_damage = source_action.get_punt_damage(candidate) + if(candidate_damage < source_action.min_damage_to_punt) + return null + if(!source_action.is_valid_punt_candidate(owner, candidate, cursor_turf)) + return null + + // Gets the distance unless its a comparison against stuff on the hovered stuff. + var/candidate_distance = hovered_turf_only ? 0 : source_action.get_effective_punt_distance(cursor_turf, candidate) + var/candidate_score = source_action.get_effective_punt_score(candidate_damage, candidate_distance) + return list( + "best_score" = candidate_score, + "best_distance" = candidate_distance, + "found_terminal_candidate" = !hovered_turf_only && candidate_damage >= source_action.strong_object_threshold, + "improved_best_target" = candidate_score > best_score || (candidate_score == best_score && candidate_distance < best_distance), + ) + /// Picks the strongest valid target on the exact hovered turf before any wider scan is considered. /datum/telekinetic_punt_preview/proc/find_best_punt_target_on_hovered_turf(turf/cursor_turf) if(!owner || !source_action || !cursor_turf) @@ -529,29 +564,37 @@ var/atom/movable/best_target var/best_score = -1 - // Hovering over tiles with objects will scan those tiles for targets and if there's at least one canidate, it will always use only those canidates. + // Hovering over tiles will scan those tiles for targets and if there's at least one canidate, it will always use only those canidates. + + // Item searching for(var/obj/item/item_target in cursor_turf) - var/item_damage = source_action.get_punt_damage(item_target) - if(item_damage < source_action.min_damage_to_punt) + var/list/item_evaluation = evaluate_punt_candidate(item_target, cursor_turf, best_score, hovered_turf_only = TRUE) + if(!item_evaluation) continue - if(!source_action.is_valid_punt_candidate(owner, item_target, cursor_turf)) + if(!item_evaluation["improved_best_target"]) continue - var/item_score = source_action.get_effective_punt_score(item_damage, 0) - if(item_score > best_score) - best_target = item_target - best_score = item_score + best_target = item_target + best_score = item_evaluation["best_score"] - // Hovering over tiles with structures will scan those tiles for targets and if there's at least one canidate, it will always use only those canidates. + // Structure searching for(var/obj/structure/structure_target in cursor_turf) - var/structure_damage = source_action.get_punt_damage(structure_target) - if(structure_damage < source_action.min_damage_to_punt) + var/list/structure_evaluation = evaluate_punt_candidate(structure_target, cursor_turf, best_score, hovered_turf_only = TRUE) + if(!structure_evaluation) + continue + if(!structure_evaluation["improved_best_target"]) + continue + best_target = structure_target + best_score = structure_evaluation["best_score"] + + // Machinery searching + for(var/obj/machinery/machinery_target in cursor_turf) + var/list/machinery_evaluation = evaluate_punt_candidate(machinery_target, cursor_turf, best_score, hovered_turf_only = TRUE) + if(!machinery_evaluation) continue - if(!source_action.is_valid_punt_candidate(owner, structure_target, cursor_turf)) + if(!machinery_evaluation["improved_best_target"]) continue - var/structure_score = source_action.get_effective_punt_score(structure_damage, 0) - if(structure_score > best_score) - best_target = structure_target - best_score = structure_score + best_target = machinery_target + best_score = machinery_evaluation["best_score"] return best_target @@ -590,6 +633,3 @@ set_cached_punt_target(null) #undef TK_PUNT_CLICK_OVERLAY -#undef TK_PUNT_CLICK_NONE -#undef TK_PUNT_CLICK_LEFT -#undef TK_PUNT_CLICK_MIDDLE diff --git a/modular_doppler/modular_powers/code/powers_action.dm b/modular_doppler/modular_powers/code/powers_action.dm index 395fa7b7e6f9b4..ba8cec4845a85c 100644 --- a/modular_doppler/modular_powers/code/powers_action.dm +++ b/modular_doppler/modular_powers/code/powers_action.dm @@ -154,6 +154,20 @@ * Trigger() -> PreActivate(owner) -> Activate(owner) -> try_use(user, target) * Click-activated powers DO NOT route through this; they use InterceptClickOn below. */ +/// We add a special override so we can always unset click abilities even if they're on cooldown +/datum/action/cooldown/power/Trigger(mob/clicker, trigger_flags, atom/target) + if(click_to_activate && !target) + var/mob/user = clicker || owner + if(!user) + return FALSE + + var/datum/action/cooldown/already_set = user.click_intercept + if(already_set == src) + // Powers should always be able to be toggled off again, even while their cooldown is running. + return unset_click_ability(user, refund_cooldown = TRUE) + + return ..() + /datum/action/cooldown/power/Activate(atom/target) var/mob/living/user = owner if(!user) @@ -319,4 +333,3 @@ Projectile action code down below /datum/action/cooldown/power/proc/on_projectile_hit(datum/source, mob/firer, atom/target, angle, hit_limb) return - diff --git a/modular_doppler/modular_powers/icons/powers/actions_icons.dmi b/modular_doppler/modular_powers/icons/powers/actions_icons.dmi index 0147cb2a232c688d0289c37df36ed46f902b3756..22f90809061c125988b02b49d65082c134436a9a 100644 GIT binary patch delta 3332 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 Ag8%>k From f736dfab03eb65611933519bf5242f04250877c6 Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Tue, 14 Jul 2026 15:31:57 +0200 Subject: [PATCH 4/7] undoes an erronous commit --- code/datums/actions/cooldown_action.dm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/code/datums/actions/cooldown_action.dm b/code/datums/actions/cooldown_action.dm index 26a2b0b8fad516..9f1bd18f29daaa 100644 --- a/code/datums/actions/cooldown_action.dm +++ b/code/datums/actions/cooldown_action.dm @@ -234,7 +234,11 @@ return InterceptClickOn(user, null, target) var/datum/action/cooldown/already_set = user.click_intercept - if(istype(already_set)) + if(already_set == src) + // if we clicked ourself and we're already set, unset and return + return unset_click_ability(user, refund_cooldown = TRUE) + + else if(istype(already_set)) // if we have an active set already, unset it before we set our's already_set.unset_click_ability(user, refund_cooldown = TRUE) From c2c44f621851b73ce8fa761f817e798bd3b4af87 Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Sat, 1 Aug 2026 12:08:58 +0200 Subject: [PATCH 5/7] Apply suggestions from code review Co-authored-by: _0Steven <42909981+00-Steven@users.noreply.github.com> Signed-off-by: TheOneAndOnlyCreeperJoe --- code/modules/vending/vendor/throwing.dm | 2 +- .../code/powers/resonant/psyker/telekinetic_punt.dm | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/code/modules/vending/vendor/throwing.dm b/code/modules/vending/vendor/throwing.dm index b84c542e726658..fc578711c61d00 100644 --- a/code/modules/vending/vendor/throwing.dm +++ b/code/modules/vending/vendor/throwing.dm @@ -44,7 +44,7 @@ ///Crush the mob that the vending machine got thrown at /obj/machinery/vending/throw_impact(atom/hit_atom, datum/thrownthing/throwingdatum) - if(isliving(hit_atom) && prob(5)) // DOPPLER MODULAR EDIT: Makes vending machines toppling chance-based, because several powers can now throw vending machines at roundstart which is just too strong. Previously: if(isliving(hit_atom)) + if(isliving(hit_atom) && prob(5)) // DOPPLER EDIT CHANGE - Makes vending machines toppling chance-based, because several powers can now throw vending machines at roundstart which is just too strong. Original: if(isliving(hit_atom)) tilt(fatty=hit_atom) return ..() diff --git a/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm index cb32fa962d5f0d..b64156e3587fa5 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm @@ -1,6 +1,6 @@ #define TK_PUNT_CLICK_OVERLAY "psyker_telekinetic_punt_cursor" /* - So, power that launches the best nearby object at people. This has a lot of nuance, especially with my insistance on being able to preview which item you will throw. + So, power that launches the best nearby object at people. This has a lot of nuance, especially with my insistence on being able to preview which item you will throw. This means we on the fly need to compute the best object, before its thrown, and in a way that does not kill the server's processing. The datum/telekentic_punt_preview below the action is the best I could do there. When moving your mouse over a tile, it gets the best nearby object to be thrown towards that tile. You can lock objects with middle click too. */ @@ -82,7 +82,7 @@ /// Handles middle-click target locking separately from the cooldowned punt activation so lock control still works while the action is cooling down. /datum/action/cooldown/power/psyker/telekinetic_punt/proc/handle_middle_click(mob/living/user, atom/target) // Datum gets you your targets so if this is happening something's gone wroooong. - if(!preview_datum || QDELETED(preview_datum)) + if(QDELETED(preview_datum)) user.balloon_alert(user, "power fizzles!") return FALSE @@ -111,7 +111,7 @@ /// Throws the currently chambered object at the clicked target turf. /datum/action/cooldown/power/psyker/telekinetic_punt/use_action(mob/living/user, atom/target) // Datum gets you your targets so if this is happening something's gone wroooong. - if(!preview_datum || QDELETED(preview_datum)) + if(QDELETED(preview_datum)) user.balloon_alert(user, "power fizzles!") return FALSE From 8f4e2ceb579e4c717127f584dbca1202f91e77e2 Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Sun, 2 Aug 2026 12:01:26 +0200 Subject: [PATCH 6/7] fixes a few merge conflict errors that got skipped. Makes punt objects max distance screen width so it feels smoother. Removes a leftover that made it include the melee force of an item. --- .../powers/resonant/psyker/telekinetic_punt.dm | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm index b64156e3587fa5..72cb9713c4d011 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm @@ -14,7 +14,9 @@ security_threat = POWER_THREAT_MAJOR value = 4 required_powers = list(/datum/power/psyker_power/telekinesis) + required_allow_subtypes = FALSE action_path = /datum/action/cooldown/power/psyker/telekinetic_punt + magic_flags = POWER_MAGIC_STANDARD // You ain't targeting their mind you're targetting their skull /datum/action/cooldown/power/psyker/telekinetic_punt name = "Telekinetic Punt" @@ -29,12 +31,10 @@ cooldown_time = 15 click_cd_override = CLICK_CD_ACTIVATE_ABILITY / 2 // I normally do not change this but it largely has to do with being able to lock with middle-mouse is affected by this. This feels smoother, in a way. - mental = FALSE // You ain't targeting their mind you're targetting their skull - /// Minimum damage an item must have to qualify for punt selection. var/min_damage_to_punt = 5 /// Maximum distance from the caster that we will consider puntable objects. - var/punt_object_distance = 8 + var/punt_object_distance = 10 /// Square radius around the cursor that we scan for candidates. var/punt_scan_radius = 6 /// Structures always count as this much base punt damage. @@ -42,10 +42,10 @@ /// Additional damage structures deal on top of their base punt damage. var/structure_bonus_damage = 0 /// Base knockback applied on a successful punt impact. - var/base_knockback = 1 + var/base_knockback = 0 /// Additional knockback granted when the punted object is a structure. - var/structure_bonus_knockback = 0 - /// Damage thresholds that marks objects as strong enough that we don't need to look further away for better, causing the expanding search area to stop expanding and only use its area for determening the best object. + var/structure_bonus_knockback = 1 + /// Damage thresholds that marks objects as strong enough that we don't need to look further away for better, causing the expanding search area to stop expanding and only use its current area for determening the best object. var/strong_object_threshold = 20 /// How much stress we generate upon use? var/stress_cost = PSYKER_STRESS_MINOR * 1.5 @@ -245,7 +245,7 @@ // Item specific calculation if(isitem(candidate)) var/obj/item/item_candidate = candidate - return max(item_candidate.throwforce, item_candidate.force) + return item_candidate.throwforce // Structures and machinery default to 20 cause structures normally do 10 + 1 knockback on impact, and we boost that by another 10. // This usually makes them desireable to punt. if(isstructure(candidate) || ismachinery(candidate)) From f77f10a898ff5c8cb93900903e434b6473c6adfd Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Tue, 4 Aug 2026 12:56:59 +0200 Subject: [PATCH 7/7] loud = not funny --- .../code/powers/resonant/psyker/telekinetic_punt.dm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm index 72cb9713c4d011..d50912961ebb83 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/psyker/telekinetic_punt.dm @@ -301,7 +301,7 @@ var/atom/throw_target = get_edge_target_turf(living_target, throw_dir) living_target.throw_at(throw_target, knockback, 2, owner) - playsound(living_target, 'sound/items/lead_pipe_hit.ogg', 75, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) // Punt does it, so does ours. Its just funny. + playsound(living_target, 'sound/items/lead_pipe_hit.ogg', 50, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) // Punt does it, so does ours. Its just funny. living_target.log_message("was hit by a telekinetically punted [source] from [owner] for [damage] damage.", LOG_VICTIM) owner?.log_message("telekinetically punted [source] into [living_target] for [damage] damage.", LOG_ATTACK) // If it has integrity aka structures, damage it instead.