Merge pull request #536 from NQNStudios:recast-hint

Quality of life: Spellcasting

This makes changes to the spellcasting UI.

* M or P to recast will no longer default to Light or Minor Bless/Minor Heal. You need to cast something before recast becomes available. This fixes #535 and I think it's disorienting when I've just started the game and M casts Light in a town that's fully lit, so the change is generally good I'd say.
* I implemented a recasting hint in the text bar, which was one of the things I mentioned in my quality-of-life checklist https://github.com/NQNStudios/cboe/issues/16. It replaces the status icons in combat mode.
* Sometimes when my eyes glaze over, I think I'm casting the spell on the wrong side of the LED. I thought there was a bug when I cast Long Light instead of Dumbfound (even though I know the distance between the two is pretty large -- I wasn't paying much attention). I thought it would be nice to highlight the name of the selected spell. Light green seemed to make more sense than red for that, because the LED turns green. Then I made the caster/target selection texts also use light green instead of red, to match. Uncastable spells are grey.
This commit is contained in:
2025-01-23 09:30:08 -05:00
committed by GitHub
13 changed files with 369 additions and 195 deletions

View File

@@ -11,6 +11,7 @@
#include <functional>
#include <sstream>
#include <string>
#include <map>
#include "dialog.hpp"
#include "gfx/tiling.hpp" // for bg
#include "fileio/resmgr/res_dialog.hpp"
@@ -45,6 +46,8 @@ cDialog* cDialog::topWindow = nullptr;
void (*cDialog::redraw_everything)() = nullptr;
std::mt19937 cDialog::ui_rand;
extern std::map<std::string,sf::Color> colour_map;
extern bool check_for_interrupt(std::string);
std::string cDialog::generateRandomString(){
@@ -86,39 +89,9 @@ sf::Color cControl::parseColor(string what){
}
}
clr.r = r, clr.g = g, clr.b = b;
}else if(what == "black")
clr.r = 0x00, clr.g = 0x00, clr.b = 0x00;
else if(what == "red")
clr.r = 0xFF, clr.g = 0x00, clr.b = 0x00;
else if(what == "lime")
clr.r = 0x00, clr.g = 0xFF, clr.b = 0x00;
else if(what == "blue")
clr.r = 0x00, clr.g = 0x00, clr.b = 0xFF;
else if(what == "yellow")
clr.r = 0xFF, clr.g = 0xFF, clr.b = 0x00;
else if(what == "aqua")
clr.r = 0x00, clr.g = 0xFF, clr.b = 0xFF;
else if(what == "fuchsia")
clr.r = 0xFF, clr.g = 0x00, clr.b = 0xFF;
else if(what == "white")
clr.r = 0xFF, clr.g = 0xFF, clr.b = 0xFF;
else if(what == "gray" || what == "grey")
clr.r = 0x80, clr.g = 0x80, clr.b = 0x80;
else if(what == "maroon")
clr.r = 0x80, clr.g = 0x00, clr.b = 0x00;
else if(what == "green")
clr.r = 0x00, clr.g = 0x80, clr.b = 0x00;
else if(what == "navy")
clr.r = 0x00, clr.g = 0x00, clr.b = 0x80;
else if(what == "olive")
clr.r = 0x80, clr.g = 0x80, clr.b = 0x00;
else if(what == "teal")
clr.r = 0x00, clr.g = 0x80, clr.b = 0x80;
else if(what == "purple")
clr.r = 0x80, clr.g = 0x00, clr.b = 0x80;
else if(what == "silver")
clr.r = 0xC0, clr.g = 0xC0, clr.b = 0xC0;
else throw -1;
}else if(colour_map.find(what) != colour_map.end()){
return colour_map[what];
}else throw -1;
return clr;
}

View File

@@ -266,6 +266,14 @@ void handle_spellcast(eSkill which_type, bool& did_something, bool& need_redraw,
short store_sp[6];
extern short spec_target_fail;
extern eSpecCtxType spec_target_type;
// Dual-caster recast hint toggle:
// Change the recast hint to mage if last spell wasn't mage
if(spell_forced && is_combat() && univ.current_pc().last_cast_type != which_type){
spell_forced = false;
univ.current_pc().last_cast_type = which_type;
need_redraw = true;
return;
}
if(!someone_awake()) {
ASB("Everyone's asleep/paralyzed.");
need_reprint = true;
@@ -1864,6 +1872,7 @@ void handle_menu_spell(eSpell spell_picked) {
spell_forced = true;
pc_casting = univ.cur_pc;
univ.current_pc().last_cast[spell_type] = spell_picked;
univ.current_pc().last_cast_type = spell_type;
if(spell_type == eSkill::MAGE_SPELLS)
store_mage = spell_picked;
else store_priest = spell_picked;
@@ -2472,6 +2481,7 @@ bool handle_keystroke(const sf::Event& event, cFramerateLimiter& fps_limiter){
// cast multi-target spell, set # targets to 0 so that space clicked doesn't matter
num_targets_left = 0;
handle_target_space(center, did_something, need_redraw, need_reprint);
advance_time(did_something, need_redraw, need_reprint);
} else if(overall_mode == MODE_SPELL_TARGET)
// Rotate a force wall
spell_cast_hit_return();

View File

@@ -611,8 +611,42 @@ void draw_text_bar() {
}
if((is_combat()) && (univ.cur_pc < 6) && !monsters_going) {
std::ostringstream sout;
sout << univ.current_pc().name << " (ap: " << univ.current_pc().ap << ')';
put_text_bar(sout.str());
cPlayer& current_pc = univ.current_pc();
sout << current_pc.name << " (ap: " << current_pc.ap << ')';
// Spellcasters print a hint for recasting.
// There's not enough space to print 2 hints for dual-casters,
// so just handle the last type cast.
eSkill type = current_pc.last_cast_type;
std::string hint_prefix = "";
std::ostringstream hint_out;
switch(type){
case eSkill::MAGE_SPELLS:
hint_prefix = "M";
break;
case eSkill::PRIEST_SPELLS:
hint_prefix = "P";
break;
// The only other expected value is eSkill::INVALID
default:
break;
}
if(!hint_prefix.empty()){
hint_out << hint_prefix << ": ";
if(current_pc.last_cast[type] != eSpell::NONE){
const cSpell& spell = (*current_pc.last_cast[type]);
if(pc_can_cast_spell(current_pc,type) && spell.cost <= current_pc.get_magic()) {
hint_out << "Recast " << spell.name();
}else{
hint_out << "Cannot recast";
}
}else{
hint_out << "No spell to recast";
}
}
put_text_bar(sout.str(), hint_out.str());
}
if((is_combat()) && (monsters_going))
// Print bar for 1st monster with >0 ap - that is monster that is going
@@ -623,7 +657,7 @@ void draw_text_bar() {
}
}
void put_text_bar(std::string str) {
void put_text_bar(std::string str, std::string right_str) {
text_bar_gworld.setActive(false);
auto& bar_gw = *ResMgr::graphics.get("textbar");
rect_draw_some_item(bar_gw, rectangle(bar_gw), text_bar_gworld, rectangle(bar_gw));
@@ -635,9 +669,13 @@ void put_text_bar(std::string str) {
rectangle to_rect = rectangle(text_bar_gworld);
to_rect.top += 7;
to_rect.left += 5;
to_rect.right -= 5;
win_draw_string(text_bar_gworld, to_rect, str, eTextMode::LEFT_TOP, style);
if(!monsters_going) {
// the recast hint will replace status icons:
if(!right_str.empty()){
// Style has to be wrap to get right-alignment
win_draw_string(text_bar_gworld, to_rect, right_str, eTextMode::WRAP, style, true);
}else if(!monsters_going) {
sf::Texture& status_gworld = *ResMgr::graphics.get("staticons");
to_rect.top -= 2;
to_rect.left = to_rect.right - 15;

View File

@@ -31,7 +31,7 @@ void redraw_screen(int refresh);
void put_background();
void draw_text_bar();
void refresh_text_bar();
void put_text_bar(std::string str);
void put_text_bar(std::string str, std::string right_str = "");
void draw_terrain(short mode = 0);
void place_trim(short q,short r,location where,ter_num_t ter_type);
void draw_trim(short q,short r,short which_trim,short which_mode);

View File

@@ -147,7 +147,7 @@ eStatMode stat_screen_mode;
short anim_step = -1;
// Spell casting globals
eSpell store_mage = eSpell::LIGHT, store_priest = eSpell::BLESS_MINOR;
eSpell store_mage = eSpell::NONE, store_priest = eSpell::NONE;
short store_mage_lev = 0, store_priest_lev = 0;
short store_spell_target = 6,pc_casting;
short num_targets_left = 0;

View File

@@ -52,6 +52,11 @@ short combat_percent[20] = {
70,70,67,62,57,52,47,42,40,40};
short who_cast,which_pc_displayed;
// Light can be cast in or out of combat
const eSpell DEFAULT_SELECTED_MAGE = eSpell::LIGHT;
// Bless can only be cast in combat, so separate defaults are needed
const eSpell DEFAULT_SELECTED_PRIEST = eSpell::HEAL_MINOR;
const eSpell DEFAULT_SELECTED_PRIEST_COMBAT = eSpell::BLESS_MINOR;
eSpell town_spell;
extern bool spell_freebie;
extern eSpecCtxType spec_target_type;
@@ -94,6 +99,9 @@ short spell_index[38] = {38,39,40,41,42,43,44,45,90,90,46,47,48,49,50,51,52,53,9
// Says which buttons hit which spells on second spell page, 90 means no button
bool can_choose_caster;
const sf::Color SELECTED_COLOUR = Colours::LIGHT_GREEN;
const sf::Color DISABLED_COLOUR = Colours::GREY;
// Dialog vars
short store_graphic_pc_num ;
short store_graphic_mode ;
@@ -490,6 +498,13 @@ bool repeat_cast_ok(eSkill type) {
what_spell = univ.party[who_would_cast].last_cast[type];
else what_spell = type == eSkill::MAGE_SPELLS ? store_mage : store_priest;
if(what_spell == eSpell::NONE){
std::ostringstream sout;
sout << "Repeat cast: No " << (type == eSkill::MAGE_SPELLS ? "mage" : "priest") << " spell stored.";
add_string_to_buf(sout.str());
return false;
}
if(!pc_can_cast_spell(univ.party[who_would_cast],what_spell)) {
add_string_to_buf("Repeat cast: Can't cast.");
return false;
@@ -1640,8 +1655,8 @@ static void draw_spell_info(cDialog& me, const eSkill store_situation, const sho
}
break;
case SELECT_ANY:
// TODO: Split off party members should probably be excluded too?
if(univ.party[i].main_status != eMainStatus::ABSENT) {
// Absent party members and split-off party members are excluded
if(univ.party[i].main_status != eMainStatus::ABSENT && univ.party[i].main_status < eMainStatus::SPLIT) {
me[id].show();
}
else {
@@ -1691,6 +1706,7 @@ static void draw_spell_pc_info(cDialog& me) {
if(univ.party[i].main_status != eMainStatus::ABSENT) {
me["pc" + n].setText(univ.party[i].name);
me["arrow" + n].hide();
if(univ.party[i].main_status == eMainStatus::ALIVE) {
me["hp" + n].setTextToNum(univ.party[i].cur_health);
me["sp" + n].setTextToNum(univ.party[i].cur_sp);
@@ -1706,7 +1722,7 @@ static void put_pc_caster_buttons(cDialog& me) {
std::string n = boost::lexical_cast<std::string>(i + 1);
if(me["caster" + n].isVisible()) {
if(i == pc_casting)
me["pc" + n].setColour(Colours::RED);
me["pc" + n].setColour(SELECTED_COLOUR);
else me["pc" + n].setColour(me.getDefTextClr());
}
}
@@ -1716,13 +1732,11 @@ static void put_pc_target_buttons(cDialog& me, short& store_last_target_darkened
if(store_spell_target < 6) {
std::string n = boost::lexical_cast<std::string>(store_spell_target + 1);
me["hp" + n].setColour(Colours::RED);
me["sp" + n].setColour(Colours::RED);
me["arrow" + n].show();
}
if((store_last_target_darkened < 6) && (store_last_target_darkened != store_spell_target)) {
std::string n = boost::lexical_cast<std::string>(store_last_target_darkened + 1);
me["hp" + n].setColour(me.getDefTextClr());
me["sp" + n].setColour(me.getDefTextClr());
me["arrow" + n].hide();
}
store_last_target_darkened = store_spell_target;
}
@@ -1740,12 +1754,16 @@ static void put_spell_led_buttons(cDialog& me, const eSkill store_situation,cons
eSpell spell = cSpell::fromNum(store_situation, spell_for_this_button);
if(store_spell == spell_for_this_button) {
led.setState(led_green);
// Text color:
led.setColour(SELECTED_COLOUR);
}
else if(pc_can_cast_spell(univ.party[pc_casting],spell)) {
led.setState(led_red);
led.setColour(me.getDefTextClr());
}
else {
led.setState(led_off);
led.setColour(DISABLED_COLOUR);
}
}
}
@@ -1862,9 +1880,7 @@ static bool pick_spell_select_led(cDialog& me, std::string id, eKeyMod mods, con
me["feedback"].setText(bad_spell);
}
else {
if(store_situation == eSkill::MAGE_SPELLS)
store_spell = (on_which_spell_page == 0) ? item_hit : spell_index[item_hit];
else store_spell = (on_which_spell_page == 0) ? item_hit : spell_index[item_hit];
store_spell = (on_which_spell_page == 0) ? item_hit : spell_index[item_hit];
draw_spell_info(me, store_situation, store_spell);
put_spell_led_buttons(me, store_situation, store_spell);
@@ -1914,6 +1930,7 @@ static bool finish_pick_spell(cDialog& me, bool spell_toast, const short store_s
if(store_situation == eSkill::MAGE_SPELLS && (*picked_spell).need_select == SELECT_NO) {
store_last_cast_mage = pc_casting;
univ.party[pc_casting].last_cast[store_situation] = picked_spell;
univ.party[pc_casting].last_cast_type = store_situation;
me.toast(false);
me.setResult<short>(store_spell);
return true;
@@ -1921,6 +1938,7 @@ static bool finish_pick_spell(cDialog& me, bool spell_toast, const short store_s
if(store_situation == eSkill::PRIEST_SPELLS && (*picked_spell).need_select == SELECT_NO) {
store_last_cast_priest = pc_casting;
univ.party[pc_casting].last_cast[store_situation] = picked_spell;
univ.party[pc_casting].last_cast_type = store_situation;
me.toast(false);
me.setResult<short>(store_spell);
return true;
@@ -1938,6 +1956,7 @@ static bool finish_pick_spell(cDialog& me, bool spell_toast, const short store_s
store_last_cast_mage = pc_casting;
else store_last_cast_priest = pc_casting;
univ.party[pc_casting].last_cast[store_situation] = picked_spell;
univ.party[pc_casting].last_cast_type = store_situation;
me.toast(true);
return true;
}
@@ -1947,7 +1966,7 @@ static bool finish_pick_spell(cDialog& me, bool spell_toast, const short store_s
//short situation; // 0 - out 1 - town 2 - combat
eSpell pick_spell(short pc_num,eSkill type) { // 70 - no spell OW spell num
using namespace std::placeholders;
eSpell store_spell = type == eSkill::MAGE_SPELLS ? store_mage : store_priest;
eSpell default_spell = type == eSkill::MAGE_SPELLS ? store_mage : store_priest;
short former_target = store_spell_target;
short dark = 6;
@@ -1995,29 +2014,38 @@ eSpell pick_spell(short pc_num,eSkill type) { // 70 - no spell OW spell num
// If in combat, make the spell being cast this PCs most recent spell
if(is_combat()) {
if(type == eSkill::MAGE_SPELLS)
store_spell = univ.party[pc_casting].last_cast[eSkill::MAGE_SPELLS];
else store_spell = univ.party[pc_casting].last_cast[eSkill::PRIEST_SPELLS];
default_spell = univ.party[pc_casting].last_cast[eSkill::MAGE_SPELLS];
else{
default_spell = univ.party[pc_casting].last_cast[eSkill::PRIEST_SPELLS];
if(default_spell == eSpell::NONE){
default_spell = DEFAULT_SELECTED_PRIEST_COMBAT;
}
}
}
if(default_spell == eSpell::NONE){
default_spell = type == eSkill::MAGE_SPELLS ? DEFAULT_SELECTED_MAGE : DEFAULT_SELECTED_PRIEST;
}
// Keep the stored spell, if it's still castable
if(!pc_can_cast_spell(univ.party[pc_casting],store_spell)) {
if(!pc_can_cast_spell(univ.party[pc_casting],default_spell)) {
if(type == eSkill::MAGE_SPELLS) {
store_spell = eSpell::LIGHT;
default_spell = DEFAULT_SELECTED_MAGE;
}
else {
store_spell = eSpell::HEAL_MINOR;
default_spell = DEFAULT_SELECTED_PRIEST;
}
}
// If a target is needed, keep the same target if that PC still targetable
if(store_spell_target < 6) {
if((*store_spell).need_select != SELECT_NO) {
if((*default_spell).need_select != SELECT_NO) {
if(univ.party[store_spell_target].main_status != eMainStatus::ALIVE)
store_spell_target = 6;
} else store_spell_target = 6;
}
short former_spell = int(store_spell) % 100;
short former_spell = int(default_spell) % 100;
// Set the spell page, based on starting spell
if(former_spell >= 38) on_which_spell_page = 1;
else on_which_spell_page = 0;
@@ -2042,9 +2070,7 @@ eSpell pick_spell(short pc_num,eSkill type) { // 70 - no spell OW spell num
cLed& led = dynamic_cast<cLed&>(castSpell[id]);
led.attachKey(key);
castSpell.addLabelFor(id, {static_cast<char>(i > 25 ? toupper(key.c) : key.c)}, LABEL_LEFT, 8, true);
if(spell_index[i] == 90){
continue;
}
// All LEDs should get the click handler and set state, because page 2 will hide them if necessary
led.setState((pc_can_cast_spell(univ.party[pc_casting],cSpell::fromNum(type,on_which_spell_page == 0 ? i : spell_index[i])))
? led_red : led_green);
led.attachClickHandler(std::bind(pick_spell_select_led, _1, _2, _3, type, std::ref(dark), std::ref(former_spell)));

View File

@@ -17,6 +17,36 @@
using boost::math::constants::pi;
using pt_idx_t = decltype(((sf::Shape*)nullptr)->getPointCount());
std::map<std::string,sf::Color> colour_map = {
{"white", Colours::WHITE},
{"black", Colours::BLACK},
{"grey", Colours::GREY},
{"gray", Colours::GREY},
{"red", Colours::RED},
{"green", Colours::GREEN},
{"blue", Colours::BLUE},
{"teal", Colours::TEAL},
{"pink", Colours::PINK},
{"yellow", Colours::YELLOW},
{"orange", Colours::ORANGE},
{"light-blue", Colours::LIGHT_BLUE},
{"shadow", Colours::SHADOW},
{"title-blue", Colours::TITLE_BLUE},
{"navy", Colours::NAVY},
{"dark_blue", Colours::DARK_BLUE},
{"dark-green", Colours::DARK_GREEN},
{"light-green", Colours::LIGHT_GREEN},
{"dark-red", Colours::DARK_RED},
// Extra Colors
{"lime", Colours::LIME},
{"aqua", Colours::AQUA},
{"fuchsia", Colours::FUCHSIA},
{"maroon", Colours::MAROON},
{"olive", Colours::OLIVE},
{"purple", Colours::PURPLE},
{"silver", Colours::SILVER}
};
// TODO: Put these classes in a header?
class EllipseShape : public sf::Shape {
float divSz;

View File

@@ -11,6 +11,7 @@
#include <vector>
#include <memory>
#include <map>
#include <SFML/Graphics/Shape.hpp>
#include "location.hpp"
@@ -48,6 +49,7 @@ void undo_clip(sf::RenderTarget& where);
namespace Colours {
const sf::Color WHITE = sf::Color::White;
const sf::Color BLACK = sf::Color::Black;
const sf::Color GREY { 0x80, 0x80, 0x80};
const sf::Color RED { 0xdd, 0x00, 0x00};
const sf::Color GREEN { 0x00, 0x88, 0x00};
const sf::Color BLUE { 0x00, 0x00, 0xdd};
@@ -55,6 +57,7 @@ namespace Colours {
const sf::Color PINK { 0xff, 0x00, 0x99};
const sf::Color YELLOW { 0xff, 0xff, 0x31};
const sf::Color ORANGE { 0xff, 0x80, 0x00};
const sf::Color LIGHT_BLUE { 0xad, 0xd8, 0xe6 }; // Spell points on dark background
// Text colours for shopping / talking
// TODO: The Windows version appears to use completely different colours?
const sf::Color SHADOW { 0x00, 0x00, 0x68}; // formerly c[3] QD colour = {0,0,26623} (shop/character name shadow, shop subtitle)
@@ -64,6 +67,14 @@ namespace Colours {
const sf::Color DARK_GREEN { 0x00, 0x60, 0x00}; // formerly c[5] QD colour = {0,40959,0} (talking buttons)
const sf::Color LIGHT_GREEN { 0x00, 0xa0, 0x00}; // formerly c[6] QD colour = {0,24575,0} (talking buttons pressed)
const sf::Color DARK_RED { 0xa0, 0x00, 0x14}; // formerly c[7] (clickable text, new in OBoE)
// Extra colors
const sf::Color LIME { 0x00, 0xFF, 0x00};
const sf::Color AQUA { 0x00, 0xFF, 0xFF};
const sf::Color FUCHSIA { 0xFF, 0x00, 0xFF};
const sf::Color MAROON { 0x80, 0x00, 0x00};
const sf::Color OLIVE { 0x80, 0x80, 0x00};
const sf::Color PURPLE { 0x80, 0x00, 0x80};
const sf::Color SILVER { 0xC0, 0xC0, 0xC0};
}
const sf::Color PRESET_WORD_ON = Colours::DARK_GREEN;

View File

@@ -149,7 +149,7 @@ static void win_draw_string(sf::RenderTarget& dest_window,rectangle dest_rect,st
short total_width = str_to_draw.getLocalBounds().width;
eTextMode mode = options.mode;
if(mode == eTextMode::WRAP && total_width < dest_rect.width())
if(mode == eTextMode::WRAP && total_width < dest_rect.width() && !options.right_align)
mode = eTextMode::LEFT_TOP;
if(mode == eTextMode::LEFT_TOP && str.find('|') != std::string::npos)
mode = eTextMode::WRAP;

View File

@@ -92,6 +92,10 @@ eSpell cSpell::fromNum(int num) {
return check;
}
// NOTE:
// asPeaceful() marks a spell as castable by pacifist PCs. It doesn't mean "only in peace mode"
// (which would make a lot of these contradictory/broken)
// Mage Spells
cSpell M_LIGHT = cSpell(eSpell::LIGHT).asType(eSkill::MAGE_SPELLS).asLevel(1)
.withCost(1).when(WHEN_COMBAT).when(WHEN_TOWN).when(WHEN_OUTDOORS).asPeaceful().finish();
@@ -211,7 +215,7 @@ cSpell M_FLIGHT = cSpell(eSpell::FLIGHT).asType(eSkill::MAGE_SPELLS).asLevel(6)
cSpell M_SHOCKWAVE = cSpell(eSpell::SHOCKWAVE).asType(eSkill::MAGE_SPELLS).asLevel(7)
.withCost(12).withRefer(REFER_IMMED).when(WHEN_COMBAT).finish();
cSpell M_BLESS_MAJOR = cSpell(eSpell::BLESS_MAJOR).asType(eSkill::MAGE_SPELLS).asLevel(7)
.withCost(8).withRefer(REFER_IMMED).when(WHEN_COMBAT).when(WHEN_TOWN).asPeaceful().finish();
.withCost(8).withRefer(REFER_IMMED).when(WHEN_COMBAT).asPeaceful().finish();
cSpell M_PARALYSIS_MASS = cSpell(eSpell::PARALYSIS_MASS).asType(eSkill::MAGE_SPELLS).asLevel(7)
.withRange(8).withCost(20).withRefer(REFER_IMMED).when(WHEN_COMBAT).finish();
cSpell M_PROTECTION = cSpell(eSpell::PROTECTION).asType(eSkill::MAGE_SPELLS).asLevel(7)

View File

@@ -105,7 +105,10 @@ public:
eRace race;
long unique_id;
// transient stuff
std::map<eSkill,eSpell> last_cast;
std::map<eSkill,eSpell> last_cast = {{ eSkill::MAGE_SPELLS, eSpell::NONE}, { eSkill::PRIEST_SPELLS, eSpell::NONE }};
// There is already a global last_spellcast_type, but that variable is for the whole party.
// This one is per-PC
eSkill last_cast_type = eSkill::INVALID;
location combat_pos;
short parry = 0;
iLiving* last_attacked = nullptr; // Note: Currently this is assigned but never read