'OHRRPGCE - CUSTOM editor helper code '(C) Copyright 1997-2020 James Paige, Ralph Versteegen, and the OHRRPGCE Developers 'Dual licensed under the GNU GPL v2+ and MIT Licenses. Read LICENSE.txt for terms and disclaimer of liability. ' This file is for utility code (generally for editing) used by CUSTOM but not by GAME. ' Some editors are also here, but ought to be split into separate files. #include "config.bi" #include "string.bi" 'for format #include "vbcompat.bi" 'for NOW #include "allmodex.bi" #include "common.bi" #include "loading.bi" #include "const.bi" #include "scrconst.bi" #include "cglobals.bi" #include "reload.bi" #include "slices.bi" #include "custom.bi" #include "customsubs.bi" #include "thingbrowser.bi" #include "sliceedit.bi" 'Subs and functions only used here DECLARE SUB cond_editor (cond as Condition, default as bool = NO, outer_state as MenuState) DECLARE SUB check_used_onetime_npcs_npcdata(bits() as integer, npcdata() as NPCType, mapnum as integer) 'Module-local variables DIM SHARED comp_strings() as string REDIM comp_strings(7) as string comp_strings(0) = "" comp_strings(1) = "=" comp_strings(2) = "<>" comp_strings(3) = "<" comp_strings(4) = "<=" comp_strings(5) = ">" comp_strings(6) = ">=" comp_strings(7) = "tag" 'debugging use only FUNCTION safe_tag_name(byval tagnum as integer) as string IF tagnum >= 1 AND tagnum <= gen(genMaxTagName) THEN RETURN load_tag_name(tagnum) ELSE RETURN "" END IF END FUNCTION 'Return YES if the tag has changed 'allowspecial: Whether to allow picking autoset tags (eg hero is alive) ' If you want to change this, use tag_set_grabber instead if possible. 'always_choice: 'Always' is an option 'allowneg: Allow set tag=OFF. ' If you want to change this, use tag_id_grabber instead if possible. FUNCTION tag_grabber (byref n as integer, state as MenuState, allowspecial as bool=YES, always_choice as bool=NO, allowneg as bool=YES) as bool DIM min as integer = 0 IF allowneg THEN min = -max_tag() IF intgrabber(n, min, max_tag()) THEN RETURN YES IF enter_space_click(state) THEN DIM browse_tag as integer browse_tag = tags_menu(n, YES, allowspecial, allowneg, always_choice) IF browse_tag <> n THEN n = browse_tag RETURN YES END IF END IF RETURN NO END FUNCTION 'A tag_grabber wrapper for set tag ON/OFF actions. 'Return YES if the tag has changed FUNCTION tag_set_grabber (byref n as integer, state as MenuState) as bool RETURN tag_grabber(n, state, NO) END FUNCTION 'A tag_grabber wrapper for tag ids to define as autoset tags: no on/off 'selection, and you usually shouldn't have multiple autoset settings for the same tag 'Return YES if the tag has changed FUNCTION tag_id_grabber (byref n as integer, state as MenuState) as bool RETURN tag_grabber(n, state, NO, , NO) 'allowneg=NO END FUNCTION LOCAL SUB tag_autoset_warning(byval tag_id as integer) notification !"This tag is automatically set or unset on the following conditions:\n" + describe_tag_autoset_places(tag_id) + !"\nThis means that you should not attempt to set or unset the tag in any other way, because your changes will be erased -- unpredictably!" END SUB 'If picktag is true, then can be used to pick a tag. In that case, allowspecial indicates whether to allow 'picking 'special' tags: those automatically set, eg. based on inventory conditions 'If showsign is true, picking a tag condition (tag=ON/OFF), and the ON/OFF condition can be selected. 'Returns a signed tag number (+ve, tag ON, -ve tag OFF). FUNCTION tags_menu (byval starttag as integer=0, byval picktag as bool=NO, byval allowspecial as bool=YES, byval showsign as bool=NO, byval always_choice as bool=NO) as integer STATIC searchstring as string 'If this method for guessing checktag mode ever fails, we can change it to be an argument DIM checktag as bool = picktag ANDALSO allowspecial DIM thisname as string DIM ret as integer = starttag IF gen(genMaxTagname) < 1 THEN gen(genMaxTagname) = 1 DIM tagid as integer vector DIM menu as BasicMenuItem vector DIM menu_size as integer = gen(genMaxTagname) + 1 IF picktag THEN menu_size += 1 IF always_choice THEN menu_size += 1 v_new menu, menu_size v_new tagid, menu_size IF picktag THEN menu[0].text = "Cancel" ELSE menu[0].text = "Previous Menu" END IF tagid[0] = -1 DIM menu_i as integer = 1 IF picktag THEN 'When picktag is true, it should be possible to clear the tag selection tagid[menu_i] = 0 IF checktag THEN menu[menu_i].text = "No Tag Check" ELSE menu[menu_i].text = "No Tag Set" END IF menu_i += 1 END IF IF always_choice THEN tagid[menu_i] = 1 'Magic value to indicate we want the "Always" tag menu[menu_i].text = "ALWAYS" menu_i += 1 END IF FOR i as integer = 2 TO gen(genMaxTagname) + 1 'Load all tag names plus the first blank name menu[menu_i].text = "Tag " & i & ":" & load_tag_name(i) tagid[menu_i] = i IF tag_is_autoset(i) THEN IF allowspecial = NO AND i <> ABS(starttag) THEN menu[menu_i].disabled = YES END IF END IF menu_i += 1 NEXT i DIM tagsign as integer tagsign = SGN(starttag) IF tagsign = 0 THEN tagsign = 1 DIM menuopts as MenuOptions menuopts.fullscreen_scrollbar = YES DIM state as MenuState state.autosize = YES state.autosize_ignore_lines = 1 IF showsign THEN state.autosize_ignore_lines = 2 END IF state.last = v_len(menu) - 1 init_menu_state state, menu state.pt = 0 'If ABS(starttag) >= 2 (valid tag) or 0 (do nothing), sets initial selection FOR i as integer = 0 to v_len(tagid) - 1 IF tagid[i] = ABS(starttag) THEN state.pt = i EXIT FOR END IF NEXT i DIM int_browsing as bool = NO DIM uninterrupted_alt_press as bool = NO DIM alt_pt as integer DIM do_search as bool = NO DIM search_cur_str as string DIM search_found as bool = NO setkeys YES DO setwait 55 setkeys YES IF keyval(ccCancel) > 1 THEN EXIT DO IF keyval(scF1) > 1 THEN show_help "tagnames" IF keyval(scCTRL) > 0 ANDALSO keyval(scF) > 1 THEN IF prompt_for_string(searchstring, "Search") THEN do_search = YES END IF END IF IF keyval(scF3) > 1 THEN do_search = YES END IF IF do_search THEN do_search = NO IF searchstring <> "" THEN FOR i as integer = large(state.pt + 1, 1) TO v_len(tagid) - 1 IF tagid[i] < 2 THEN CONTINUE FOR search_cur_str = load_tag_name(tagid[i]) IF INSTR(LCASE(search_cur_str), LCASE(searchstring)) THEN state.pt = i correct_menu_state state search_found = YES EXIT FOR END IF NEXT i IF NOT search_found THEN '--wrap the search FOR i as integer = 1 TO state.pt - 1 IF tagid[i] < 2 THEN CONTINUE FOR search_cur_str = load_tag_name(tagid[i]) IF INSTR(LCASE(search_cur_str), LCASE(searchstring)) THEN state.pt = i correct_menu_state state search_found = YES EXIT FOR END IF NEXT i END IF search_found = NO END IF END IF IF usemenu(state) THEN IF tagid[state.pt] >= 2 THEN alt_pt = tagid[state.pt] ELSE alt_pt = 0 END IF END IF IF keyval(scAlt) AND 4 THEN uninterrupted_alt_press = YES IF keyval(scAlt) = 0 AND uninterrupted_alt_press = YES THEN uninterrupted_alt_press = NO int_browsing XOR= YES IF tagid[state.pt] >= 2 THEN alt_pt = tagid[state.pt] ELSE alt_pt = 0 END IF END IF IF int_browsing THEN IF intgrabber(alt_pt, 0, gen(genMaxTagName) + 1) THEN FOR i as integer = 0 TO v_len(tagid) - 1 IF alt_pt = tagid[i] THEN state.pt = i correct_menu_state state EXIT FOR END IF NEXT i END IF ELSEIF showsign THEN IF keyval(ccLeft) > 1 ORELSE keyval(ccRight) > 1 THEN tagsign = tagsign * -1 END IF END IF IF tagid[state.pt] = -1 AND enter_space_click(state) THEN 'We want to cancel out with no changes ret = starttag EXIT DO END IF IF tagid[state.pt] = 0 AND enter_space_click(state) THEN 'We want to return 0, clearing the tag set/check ret = 0 EXIT DO END IF IF tagid[state.pt] = 1 AND enter_space_click(state) THEN 'We want to return -1, indicating a tag-check of "ALWAYS" IF NOT checktag THEN debug "tags_menu() returned -1 ALWAYS when not in checktag mode." ret = -1 EXIT DO END IF IF tagid[state.pt] >= 2 THEN IF keyval(scTab) > 1 ANDALSO tag_is_autoset(tagid[state.pt]) THEN tag_autoset_warning tagid[state.pt] END IF IF enter_click(state) THEN ' Can't call enter_space_click() because we can type spaces when editing tag names IF menu[state.pt].disabled THEN tag_autoset_warning tagid[state.pt] ELSEIF picktag THEN ret = tagid[state.pt] * tagsign EXIT DO END IF END IF thisname = safe_tag_name(tagid[state.pt]) IF int_browsing = NO ANDALSO strgrabber(thisname, 20) THEN uninterrupted_alt_press = NO save_tag_name thisname, tagid[state.pt] menu[state.pt].text = "Tag " & tagid[state.pt] & ":" & thisname IF tagid[state.pt] = gen(genMaxTagName) + 1 THEN IF gen(genMaxTagName) < max_tag() THEN gen(genMaxTagName) += 1 v_resize menu, v_len(menu) + 1 v_resize tagid, v_len(tagid) + 1 tagid[state.pt + 1] = tagid[state.pt] + 1 menu[state.pt + 1].text = "Tag " & tagid[state.pt + 1] & ":" state.last += 1 END IF END IF END IF END IF clearpage dpage standardmenu menu, state, , , dpage, menuopts DIM tmpstr as string IF int_browsing THEN textcolor uilook(uiText), uilook(uiHighlight) tmpstr = "Tag " & alt_pt ELSE textcolor uilook(uiDisabledItem), 0 tmpstr = "Alt:Tag #" END IF printstr tmpstr, pInfoRight, pMenuY, dpage IF NOT int_browsing THEN tmpstr = "CTRL+F Search" printstr tmpstr, pInfoRight, pMenuY + 10, dpage IF LEN(searchstring) > 0 THEN tmpstr = "F3 Again" printstr tmpstr, pInfoRight, pMenuY + 20, dpage END IF END IF IF showsign THEN ' Show whether we are picking a tag that can be ON or OFF DIM signstr as string IF checktag THEN signstr = "Check if tag is" ELSE signstr = "Set tag =" END IF signstr = signstr & " " & IIF(tagsign = 1, "ON", "OFF") textcolor uilook(uiText), 0 DIM signrect as RectType signrect = str_rect(signstr, 0 , 0) signrect.x = vpages(dpage)->w - 4 - signrect.size.x signrect.y = vpages(dpage)->h - 16 IF rect_collide_point(signrect, readmouse.pos) THEN textcolor uilook(uiSelectedItem + state.tog), uilook(uiHighlight) IF readmouse.release AND mouseLeft THEN tagsign *= -1 END IF printstr signstr, pRight - 4, pBottom - 8, dpage END IF IF tag_is_autoset(tagid[state.pt]) THEN 'Showing tag autoset status is not important when using picktag for a tag check 'so we only show it when in set-tag more or non-tag-picking mode textcolor uilook(uiDisabledItem), 0 printstr "An auto-set tag. Press TAB for details", pInfoX, pInfoY, dpage END IF SWAP vpage, dpage setvispage vpage dowait LOOP setkeys v_free menu RETURN ret END FUNCTION 'default: meaning of the null condition (true: ALWAYS, false: NEVER) 'alwaysedit: experimental parameter, changes behaviour of enter/space 'Return value is currently very unreliable. FUNCTION cond_grabber (cond as Condition, default as bool = NO, alwaysedit as bool, st as MenuState) as bool DIM intxt as string = getinputtext DIM entered_operator as bool = NO DIM temp as integer WITH cond 'debug "cond_grabber: .comp = " & comp_strings(.comp) & " tag/var = " & .tag & " value = " & .value & " editst = " & .editstate & " lastchar = " & CHR(.lastinput) & " default = " & default IF keyval(scDelete) > 1 THEN .comp = 0 RETURN YES END IF 'Simplify IF .comp = compTag AND .tag = 0 THEN .comp = 0 'enter_or_space IF .comp = compTag AND alwaysedit = NO THEN IF enter_or_space() THEN DIM browse_tag as integer browse_tag = tags_menu(.tag, YES, YES) IF browse_tag >= 2 OR browse_tag <= -2 THEN .tag = browse_tag RETURN YES ELSE 'Return once enter/space processed RETURN NO END IF END IF ELSE IF keyval(scAnyEnter) > 1 THEN cond_editor(cond, default, st) END IF CONST compare_chars as string = "=<>!" 'Use strings instead of integers for convenience -- have to decode to use STATIC statetable(3, 7) as zstring * 3 => { _ /'Current comparison type: '/ _ /'None = <> < <= > >= Tag '/ _ {"=" ,"=" ,"=" ,"<=","=" ,">=","=" ,"=" }, /' = pressed '/ _ {"<" ,"<=","<" ,"<" ,"<" ,"<>","<" ,"<" }, /' < pressed '/ _ {">" ,">=",">" ,"<>",">" ,">" ,">" ,">" }, /' > pressed '/ _ {"<>","<>","=" ,">=",">" ,"<=","<" ,"<>"} /' ! pressed '/ _ } 'Listen for comparison operator input FOR i as integer = 1 TO LEN(intxt) DIM inchar as string = MID(intxt, i) DIM charnum as integer = INSTR(compare_chars, inchar) IF charnum THEN entered_operator = YES DIM newcomp as CompType = -1 IF .comp = compNone OR .comp = compTag OR .editstate = 1 OR .editstate = 5 THEN 'Ignore the current operator; we're pretending there is none newcomp = a_findcasei(comp_strings(), inchar) ELSE 'First check whether in the middle of typing a comparison operator. 'This special check ensure that eg. typing >= causes the operator to 'change to >= regardless of initial state IF .lastinput THEN 'Only checking input strings of len 2 newcomp = a_findcasei(comp_strings(), CHR(.lastinput) + inchar) END IF IF newcomp = -1 THEN 'This _temp variable is to work around a FB bug, https://sourceforge.net/p/fbc/bugs/816/ '(Fixed in FB 1.06.) 'It only occurs when compiling with debug=0 (without -exx. Whether adding/removing -exx causes 'the bug to occur depends on the surrounding context in the function). DIM _temp as integer = charnum - 1 DIM tempcomp as string = statetable(_temp, .comp) newcomp = a_findcasei(comp_strings(), tempcomp) END IF END IF IF newcomp > -1 THEN IF .comp = compNone OR .comp = compTag THEN 'In future, largest allowable tag ID will increase .varnum = small(ABS(.tag), maxScriptGlobals) .value = 0 .editstate = 2 END IF .comp = newcomp END IF END IF .lastinput = ASC(inchar) NEXT 'Other input: a finite state machine IF .comp = compNone THEN 'No need to check for entered_operator: the comp would have changed .tag = 0 IF intgrabber(.tag, -max_tag(), max_tag()) THEN .comp = compTag END IF ELSEIF .comp = compTag THEN 'editstate meaning (asterisks indicate highlighting) '0: Tag #=OFF/ON (no highlight) '1: Tag *#*=OFF/ON 'No need to check for entered_operator IF INSTR(intxt, "!") THEN .tag = -.tag ELSE intgrabber(.tag, -max_tag(), max_tag()) END IF ELSE 'Globals .varnum = bound(.varnum, 0, maxScriptGlobals) 'Could be negative if it was a tag condition 'editstate is just a state id, defining the way the condition is edited and displayed '(below, asterisks indicate highlighting) '0: Global # .. # (initial) '1: Global *#* '2: Global # *..* '3: Global # .. *#* '4: Global # *..* # '5: Global # *?* # '6: Global *#* .. # SELECT CASE .editstate CASE 0 IF keyval(scTab) > 1 THEN .editstate = 3 ELSEIF keyval(scBackspace) > 1 THEN 'Backspace works from the right... intgrabber(.value, -2147483648, 2147483647) .editstate = 3 ELSEIF entered_operator THEN .editstate = 4 ELSE '...and numerals enter from the left temp = 0 'Don't erase previous value when trying to inc/decrement it IF keyval(ccLeft) > 0 OR keyval(ccRight) > 0 THEN temp = .varnum IF intgrabber(temp, 0, maxScriptGlobals, , , YES) THEN .varnum = temp .editstate = 6 END IF END IF CASE 1, 6 IF .editstate = 6 AND keyval(scTab) > 1 THEN .editstate = 3 ELSEIF entered_operator THEN IF .editstate = 1 THEN .editstate = 2 ELSE .editstate = 4 ELSEIF keyval(scBackspace) > 1 AND .varnum = 0 THEN .editstate = 0 .comp = compNone ELSE intgrabber(.varnum, 0, maxScriptGlobals) END IF CASE 3 IF keyval(scTab) > 1 THEN .editstate = 6 ELSEIF entered_operator THEN .editstate = 4 ELSEIF keyval(scBackspace) > 1 AND .value = 0 THEN .editstate = 2 ELSE intgrabber(.value, -2147483648, 2147483647) END IF CASE 2, 4, 5 'Operator editing IF keyval(scTab) > 1 THEN .editstate = 3 ELSEIF .editstate = 5 AND entered_operator THEN .editstate = 4 ELSEIF keyval(scBackspace) > 1 THEN DIM newcomp as string = comp_strings(.comp) IF .editstate = 5 THEN 'state 5 simulates LEN(newcomp) = 0 .editstate = 1 ELSEIF LEN(newcomp) = 1 THEN IF .editstate = 2 THEN .editstate = 1 ELSEIF .editstate = 4 THEN .editstate = 5 END IF ELSE 'LEN = 2 .comp = a_findcasei(comp_strings(), LEFT(newcomp, 1)) END IF ELSE temp = 0 'IF .editstate <> 2 THEN temp = .value 'Don't erase previous value when trying to inc/decrement it IF keyval(ccLeft) > 0 OR keyval(ccRight) > 0 THEN temp = .value IF intgrabber(temp, -2147483648, 2147483647, , , YES) THEN .value = temp .editstate = 3 END IF END IF END SELECT END IF END WITH 'FIXME: check if anything changed, and return YES if so END FUNCTION 'default: meaning of the null condition (true: ALWAYS, false: NEVER) SUB cond_editor (cond as Condition, default as bool = NO, outer_state as MenuState) DIM menu(10) as string DIM compty(10) as integer 'CompType (can't pass that to a_find) menu(0) = "Cancel" menu(1) = "Always" menu(2) = "Never" menu(3) = "Tag # ON" : compty(3) = compTag menu(4) = "Tag # OFF" : compty(4) = compTag menu(5) = "Global # = #" : compty(5) = compEq menu(6) = "Global # <> #" : compty(6) = compNe menu(7) = "Global # < #" : compty(7) = compLt menu(8) = "Global # <= #" : compty(8) = compLe menu(9) = "Global # > #" : compty(9) = compGt menu(10) = "Global # >= #" : compty(10) = compGe DIM st as MenuState st.last = UBOUND(menu) st.size = st.last + 1 DIM starttag as integer = 1 ' Determine initial menu selection IF cond.comp = compTag AND cond.tag = 0 THEN cond.comp = compNone IF cond.comp = compNone THEN st.pt = IIF(default, 1, 2) ELSEIF cond.comp = compTag THEN starttag = ABS(cond.tag) IF cond.tag = 1 THEN st.pt = 2 ELSEIF cond.tag = -1 THEN st.pt = 1 ELSEIF cond.tag >= 2 THEN st.pt = 3 ELSE st.pt = 4 END IF ELSE st.pt = a_find(compty(), cond.comp) IF st.pt = -1 THEN st.pt = 1 'If cond.comp is invalid END IF DIM menuopts as MenuOptions menuopts.wide = 13 * 8 ' Minimum width menuopts.calc_size = YES ' Position to draw the menu (calculated next) DIM mpos as XYPair = (60, 0) ' Precompute the menu size calc_menu_rect st, menuopts, mpos, vpage ' Calculate screen position of the outer menu ' (Note: MenuState doesn't tell where the menu will be drawn; we have to assume 0,0!) WITH outer_state ' Position the new menu so that the initially selected menu item ' is at the same y position as the current item in the previous menu mpos.y = (.pt - .top) * .spacing - (st.pt - st.top) * st.spacing ' Make sure the new position is fully onscreen (and leave extra space at the bottom of the screen) mpos.y = bound(mpos.y, 0, vpages(vpage)->h - .spacing) IF mpos.y + st.rect.high > vpages(vpage)->h - 15 THEN mpos.y -= st.rect.high - st.spacing END WITH DIM holdpage as integer = allocatepage copypage vpage, holdpage DO setwait 55 setkeys IF keyval(ccCancel) > 1 THEN EXIT DO IF keyval(scF1) > 1 THEN show_help "cond_editor" ' Typing a number could be any type. Select what's under the cursor IF compty(st.pt) <> 0 AND INSTR(getinputtext, ANY "0123456789") > 0 THEN cond.comp = compty(st.pt) cond.varnum = 0 'Clear ID so you can type it in cond.editstate = 6 'Editing global ID. Also valid editstate for tags. EXIT DO END IF ' If you start typing a relation, exit to cond_grabber which will handle it ' FIXME: regardless of editstate, the cond_grabber ignores the keypress IF INSTR(getinputtext, ANY "=<>!") > 0 THEN IF cond.comp = compTag OR cond.comp = compNone THEN cond.comp = compEq cond.editstate = 6 EXIT DO END IF ' Exit on TAB so that you simultaneously change to the selected comparison ' and cond_grabber processes the TAB. ' Also, press TAB to select a tag option but skip the tag browser. IF enter_space_click(st) OR keyval(scTab) > 1 THEN IF compty(st.pt) THEN cond.comp = compty(st.pt) SELECT CASE st.pt CASE 0: EXIT DO CASE 1: IF default THEN cond.comp = compNone ELSE cond.comp = compTag cond.tag = -1 END IF CASE 2: IF default = NO THEN cond.comp = compNone ELSE cond.comp = compTag cond.tag = 1 END IF CASE 3, 4: IF st.pt = 4 THEN starttag *= -1 'tag=OFF IF keyval(scTab) > 1 THEN cond.tag = starttag ELSE cond.tag = tags_menu(starttag, YES, YES) END IF CASE ELSE: 'TODO: global variable browser cond.editstate = 6 'start by entering global variable number END SELECT EXIT DO END IF usemenu st copypage holdpage, vpage edgeboxstyle mpos.x - 5, mpos.y - 5, st.rect.wide + 10, st.rect.high + 10, 2, vpage DIM msg as string IF compty(st.pt) = compNone THEN ELSEIF compty(st.pt) = compTag THEN msg = "ENTER to pick tag/TAB confirm" ELSE msg = "Type expression, TAB to switch" END IF edgeprint "F1 Help " + msg, pLeft, pBottom, uilook(uiText), vpage standardmenu menu(), st, mpos.x, mpos.y, vpage, menuopts setvispage vpage dowait LOOP freepage holdpage END SUB 'Returns a printable representation of a Condition with lots of ${K} colours 'default: the text displayed for a null Condition 'selected: whether this menu item is selected 'wide: max string length to return (not implemented yet) FUNCTION condition_string (cond as Condition, selected as bool, default as string = "Always", wide as integer = 40) as string DIM ret as string = default DIM hlcol as integer = uilook(uiHighlight2) IF selected = NO THEN cond.editstate = 0 cond.lastinput = 0 ELSEIF cond.editstate = 0 THEN ' Set initial edit state to highlight the relevant part IF cond.comp = compTag THEN cond.editstate = 1 ELSEIF cond.comp <> compNone THEN cond.editstate = 6 ' Initially, the global ID is edited END IF END IF IF cond.comp = compNone THEN ELSEIF cond.comp = compTag THEN IF cond.tag = 0 THEN ELSE IF cond.editstate = 0 THEN ret = "Tag " & ABS(cond.tag) ELSE ret = "Tag " & hilite(STR(ABS(cond.tag)), hlcol) END IF ret += IIF(cond.tag >= 0, "=ON", "=OFF") IF cond.tag = 1 THEN ret += " [Never]" ELSEIF cond.tag = -1 THEN ret += " [Always]" ELSE ret += " (" & load_tag_name(ABS(cond.tag)) & ")" END IF END IF ELSEIF cond.comp >= compEq AND cond.comp <= compGe THEN SELECT CASE cond.editstate CASE 0 ret = "Global #" & cond.varnum & " " & comp_strings(cond.comp) & " " & cond.value CASE 1 ret = "Global #" & hilite(str(cond.varnum), hlcol) CASE 2 ret = "Global #" & cond.varnum & " " & hilite(comp_strings(cond.comp), hlcol) CASE 3 ret = "Global #" & cond.varnum & " " & comp_strings(cond.comp) & hilite(" " & cond.value, hlcol) CASE 4 ret = "Global #" & cond.varnum & " " & hilite(comp_strings(cond.comp), hlcol) & " " & cond.value CASE 5 'FIXME: a tag for text background colour hasn't been implemented yet ret = "Global #" & cond.varnum & hilite(" ? ", hlcol) & cond.value CASE 6 ret = "Global #" & hilite(cond.varnum & " ", hlcol) & comp_strings(cond.comp) & " " & cond.value END SELECT ELSE ret = "[Corrupt condition data]" END IF IF selected THEN ' Provide an indication that you can press Enter ret += "..." END IF RETURN ret END FUNCTION FUNCTION charpicker() as string STATIC pt as integer DIM i as integer DIM f(255) as integer DIM last as integer = -1 DIM linesize as integer DIM offset as XYPair FOR i = 32 TO 255 last = last + 1 f(last) = i NEXT i linesize = 16 offset.x = 160 - (linesize * 9) \ 2 offset.y = 100 - ((last \ linesize) * 9) \ 2 DIM tog as integer = 0 setkeys DO setwait 55 setkeys tog = tog XOR 1 IF keyval(ccCancel) > 1 THEN setkeys RETURN "" END IF IF keyval(scF1) > 1 THEN show_help "charpicker" IF keyval(ccUp) > 1 THEN pt = large(pt - linesize, 0) IF keyval(ccDown) > 1 THEN pt = small(pt + linesize, last) IF keyval(ccLeft) > 1 THEN pt = large(pt - 1, 0) IF keyval(ccRight) > 1 THEN pt = small(pt + 1, last) IF enter_or_space() THEN setkeys RETURN CHR(f(pt)) END IF DIM hover as integer = -1 FOR i = 0 TO last IF rect_collide_point(XYWH(offset.x + (i MOD linesize) * 9, offset.y + (i \ linesize) * 9, 9, 9), readmouse.pos) THEN hover = i NEXT i IF hover >= 0 THEN IF (readmouse.buttons AND mouseLeft) ORELSE (readmouse.buttons AND mouseRight) THEN pt = hover END IF IF (readmouse.release AND mouseLeft) THEN setkeys RETURN CHR(f(pt)) END IF ELSE 'outside of menu area IF (readmouse.release AND mouseLeft) ORELSE (readmouse.release AND mouseRight) THEN setkeys RETURN "" END IF END IF clearpage dpage DIM col as integer DIM bg as integer FOR i = 0 TO last col = uilook(uiMenuItem) bg = uilook(uiDisabledItem) IF (i MOD linesize) = (pt MOD linesize) OR (i \ linesize) = (pt \ linesize) THEN col = uilook(uiMenuItem) bg = uilook(uiHighlight) END IF IF pt = i THEN col = uilook(uiSelectedItem + tog) bg = 0 END IF IF hover = i THEN col = mouse_hover_tinted_color(col) bg = mouse_hover_tinted_color(bg) END IF textcolor col, bg printstr CHR(f(i)), offset.x + (i MOD linesize) * 9, offset.y + (i \ linesize) * 9, dpage NEXT i textcolor uilook(uiMenuItem), 0 printstr "ASCII " & f(pt), 78, 190, dpage FOR i = 2 TO 53 IF f(pt) = ASC(key2text(2, i)) THEN printstr "ALT+" + UCASE(key2text(0, i)), 178, 190, dpage IF f(pt) = ASC(key2text(3, i)) THEN printstr "ALT+SHIFT+" + UCASE(key2text(0, i)), 178, 190, dpage NEXT i IF f(pt) = 32 THEN printstr "SPACE", 178, 190, dpage SWAP vpage, dpage setvispage vpage dowait LOOP END FUNCTION 'Return initial representation string for percent_cond_grabber FUNCTION format_percent_cond(cond as AttackElementCondition, default as string, byval decimalplaces as integer = 4) as string IF cond.comp = compNone THEN RETURN default ELSE RETURN " " + comp_strings(cond.comp) + " " + format_percent(cond.value, decimalplaces) END IF END FUNCTION 'This will probably only be used for editing AttackElementConditions, but it's more general than that. 'Returns whether cond was edited. If ret_if_repr_changed, also returns true if repr changed. FUNCTION percent_cond_grabber(byref cond as AttackElementCondition, byref repr as string, default as string, byval min as double, byval max as double, byval decimalplaces as integer = 4, ret_if_repr_changed as bool = YES) as bool WITH cond DIM intxt as string = getinputtext DIM newcomp as CompType = .comp DIM oldrepr as string = repr DIM ret as bool IF keyval(scDelete) > 1 THEN newcomp = compNone 'Listen for comparison operator input IF INSTR(intxt, "<") THEN newcomp = compLt IF INSTR(intxt, ">") THEN newcomp = compGt IF newcomp <> .comp THEN IF .comp = compNone THEN .value = 0 .comp = newcomp repr = format_percent_cond(cond, default, decimalplaces) ret = YES ELSEIF .comp = compNone THEN DIM temp as string = "0%" .value = 0 'typing 0 doesn't change the value or repr, workaround IF percent_grabber(.value, temp, min, max, decimalplaces, NO) OR INSTR(intxt, "0") > 0 THEN repr = " < " + temp 'Default .comp = compLt ret = YES END IF ELSE 'Trim comparison operator repr = MID(repr, 4) IF keyval(scBackspace) > 1 ANDALSO repr = "0%" THEN repr = default .comp = compNone ret = YES ELSE ret OR= percent_grabber(.value, repr, min, max, decimalplaces, NO) 'Add the operator back repr = " " + comp_strings(.comp) + " " + repr END IF END IF IF ret_if_repr_changed THEN ret OR= (repr <> oldrepr) RETURN ret END WITH END FUNCTION SUB percent_cond_editor (cond as AttackElementCondition, byval min as double, byval max as double, byval decimalplaces as integer = 4, do_what as string = "...", percent_of_what as string = "") DIM cond_types(2) as CompType = {compNone, compLt, compGt} DIM comp_num as CompType FOR i as integer = 0 TO 2 IF cond.comp = cond_types(i) THEN comp_num = i NEXT DIM menu(2) as string menu(0) = "Previous Menu" DIM st as MenuState st.size = 18 st.pt = 1 DIM repr as string = format_percent(cond.value, decimalplaces) DO setwait 55 setkeys YES IF keyval(ccCancel) > 1 OR enter_space_click(st) THEN EXIT DO IF keyval(scF1) > 1 THEN show_help "percent_cond_editor" SELECT CASE st.pt CASE 1: IF intgrabber(comp_num, 0, 2) THEN cond.comp = cond_types(comp_num) CASE 2: percent_grabber(cond.value, repr, min, max, decimalplaces) END SELECT 'Update IF cond.comp = compNone THEN menu(1) = "Condition: Never" IF cond.comp = compGt THEN menu(1) = "Condition: " + do_what + " when more than..." IF cond.comp = compLt THEN menu(1) = "Condition: " + do_what + " when less than..." menu(2) = "Threshold: " + repr + percent_of_what st.last = IIF(cond.comp = compNone, 1, 2) usemenu st clearpage vpage standardmenu menu(), st, , , vpage setvispage vpage dowait LOOP END SUB 'Whether thinggrabber will enter an editor. FUNCTION enter_or_add_new(state as MenuState) as bool RETURN enter_space_click(state) OR keyval(scPlus) > 1 OR keyval(scNumpadPlus) > 1 OR keyval(scInsert) > 1 END FUNCTION 'Returns true if the calling menu needs to refresh. 'Edit an object ID numerically (unless intgrab=NO) or by entering an editor. 'See FnEditor for information about the calling convention. 'This handles enter/space/click, +/insert, digits, left/right, - and backspace. 'offset should be 0 or 1, eg: ' offset=0: datum X means record (eg attack) X ' offset=1: datum 0 is none, X+1 is record X 'min is the minimum value of datum. Usually equal to offset if there is no '"none" option, or offset-1 if there is. (Not the same as min for zintgrabber!) 'max_record is the maximum object ID, not the upper range of datum. 'add_new_if_none: pass YES only if Enter/Space/Click already was diverted to ThingBrowser. LOCAL FUNCTION thinggrabber (byref datum as integer, state as MenuState, offset as integer, min as integer, max_record as integer, intgrab as bool = YES, editor_func as FnEditor, add_new_if_none as bool = NO) as bool DIM editor_arg as integer 'What to pass to editor_func. IF keyval(scPlus) > 1 OR keyval(scNumpadPlus) > 1 OR keyval(scInsert) > 1 THEN editor_arg = max_record + 1 ELSEIF enter_space_click(state) THEN ' (Note: this case generally isn't reached, it's handled in attackgrabber, etc, ' unless Shift or Ctrl is held) editor_arg = datum - offset ' If editor_arg < 0, ie initial value is None, then could either go to editor ' for record 0 or add new. Shouldn't add new if there's no ThingBrowser to enter. IF editor_arg < 0 THEN editor_arg = IIF(add_new_if_none, max_record + 1, 0) END IF ELSE ' Check this only after checking scPlus, because intgrabber reads it IF intgrab THEN IF offset <> 0 AND offset <> 1 THEN showbug "thinggrabber: weird offset " & offset IF offset = 0 AND min = -1 ANDALSO (keyval(scDelete) > 1 ORELSE (keyval(scBackspace) > 1 AND datum <= 0)) THEN 'Special case, intgrabber doesn't know that -1 means None, not 0 IF datum <> -1 THEN datum = -1 RETURN YES END IF 'min and max for zintgrabber are offset by one: -1 means datum=0 ELSEIF (offset = 1 ANDALSO zintgrabber(datum, min - 1, max_record)) OR _ (offset = 0 ANDALSO intgrabber(datum, min, max_record)) THEN RETURN YES END IF END IF RETURN NO END IF DIM newrecord as integer newrecord = editor_func(editor_arg) IF newrecord = -1 THEN RETURN NO 'Cancelled add-new datum = newrecord + offset ' Even if the ID hasn't changed you might have edited the data, so need to refresh the menu RETURN YES END FUNCTION LOCAL FUNCTION want_to_enter_browser(state as MenuState) as bool RETURN keyval(scCtrl) = 0 ANDALSO keyval(scShift) = 0 ANDALSO enter_space_click(state) END FUNCTION 'Edit an attack ID (possibly offset) numerically or by entering the attack editor. 'See thinggrabber. FUNCTION attackgrabber (byref datum as integer, state as MenuState, offset as integer = 0, min as integer = 0, intgrab as bool = YES) as bool IF want_to_enter_browser(state) THEN IF offset = 1 THEN datum = attack_picker_or_none(datum) ELSE datum = attack_picker(datum) END IF RETURN YES ELSE RETURN thinggrabber(datum, state, offset, min, gen(genMaxAttack), intgrab, @attack_editor, YES) END IF END FUNCTION 'Edit an enemy ID (possibly offset) numerically or by entering the enemy editor. 'See thinggrabber. FUNCTION enemygrabber (byref datum as integer, state as MenuState, offset as integer = 0, min as integer = 0, intgrab as bool = YES) as bool IF want_to_enter_browser(state) THEN IF offset = 1 THEN datum = enemy_picker_or_none(datum) ELSE datum = enemy_picker(datum) END IF RETURN YES ELSE RETURN thinggrabber(datum, state, offset, min, gen(genMaxEnemy), intgrab, @enemy_editor, YES) END IF END FUNCTION 'Edit a formation ID (possibly offset) numerically or by entering the formation editor. 'See thinggrabber. FUNCTION formationgrabber (byref datum as integer, state as MenuState, offset as integer = 0, min as integer = 0, intgrab as bool = YES) as bool IF want_to_enter_browser(state) THEN IF offset = 1 THEN datum = formation_picker_or_none(datum) ELSE datum = formation_picker(datum) END IF RETURN YES ELSE RETURN thinggrabber(datum, state, offset, min, gen(genMaxFormation), intgrab, @individual_formation_editor, YES) END IF END FUNCTION 'Edit a textbox ID (possibly offset) numerically or by entering the textbox editor. 'See thinggrabber. FUNCTION textboxgrabber (byref datum as integer, state as MenuState, offset as integer = 0, min as integer = 0, intgrab as bool = YES) as bool 'TODO: textbox 0 is never selectable, but ID 0 is None. So this acts a bit weird. IF want_to_enter_browser(state) THEN IF offset = 1 THEN datum = textbox_picker_or_none(datum) ELSE datum = textbox_picker(datum) END IF RETURN YES ELSE RETURN thinggrabber(datum, state, offset, min, gen(genMaxTextbox), intgrab, @text_box_editor, YES) END IF END FUNCTION ' Shared by ui_color_editor and ui_boxstyle_editor SUB make_ui_editor_sample_menu(sample_menu as MenuDef, sample_state as MenuState) WITH sample_menu .alignhoriz = alignRight .alignvert = alignTop .anchorhoriz = alignRight .anchorvert = alignTop .offset.x = -8 .offset.y = 4 END WITH append_menu_item sample_menu, "Sample" append_menu_item sample_menu, "Example" append_menu_item sample_menu, "Disabled" sample_menu.last->disabled = YES sample_state.active = YES init_menu_state sample_state, sample_menu END SUB SUB ui_color_editor(palnum as integer) DIM index as integer DIM default_colors(uiColorLast) as integer load_master_and_uicol palnum GuessDefaultUIColors master(), default_colors() DIM sample_menu as MenuDef DIM sample_state as MenuState make_ui_editor_sample_menu sample_menu, sample_state DIM color_menu(-1 TO uiColorLast) as string make_ui_color_editor_menu color_menu(), uilook() DIM state as MenuState state.first = -1 state.top = -1 state.pt = -1 state.last = UBOUND(color_menu) state.autosize = YES state.autosize_ignore_pixels = 18 setkeys DO setwait 55 setkeys IF keyval(ccCancel) > 1 THEN EXIT DO IF keyval(scF1) > 1 THEN show_help "ui_color_editor" IF usemenu(state) THEN IF uilook(uiShadow) = 0 THEN 'This is a hack. Unfortunately ellipse slice border and fill colors can't be 0 as that 'counts as transparent. This is a design mistake in ellipse slices, but is too much trouble to fix uilook(uiShadow) = nearcolor(master(), 0, 1) state.need_update = YES END IF END IF mouse_update_hover sample_state index = state.pt IF enter_space_click(state) THEN IF state.pt = -1 THEN EXIT DO ELSE 'Color browser uilook(index) = color_browser_256(uilook(index)) state.need_update = YES END IF END IF IF index >= 0 THEN state.need_update OR= intgrabber(uilook(index), 0, 255) IF keyval(scCtrl) > 0 AND keyval(scD) > 1 THEN ' Ctrl+D uilook(index) = default_colors(index) state.need_update = YES END IF END IF IF state.need_update THEN state.need_update = NO make_ui_color_editor_menu color_menu(), uilook() SaveUIColors uilook(), boxlook(), palnum END IF '--update sample according to what you have highlighted DIM draw_sample as bool = NO sample_state.pt = 0 SELECT CASE index CASE uiMenuItem, uiDisabledItem, uiSelectedItem, uiSelectedItem2, uiOutline, uiMouseHoverItem draw_sample = YES CASE uiSelectedDisabled, uiSelectedDisabled2 sample_state.pt = 2 draw_sample = YES END SELECT '--draw screen clearpage dpage IF draw_sample THEN draw_menu sample_menu, sample_state, dpage standardmenu color_menu(), state, 10, pMenuY, dpage FOR i as integer = large(state.top, 0) TO small(state.top + state.size, state.last) rectangle 0, pMenuY + 9 * (i - state.top), 9, 9, uilook(i), dpage NEXT i DIM msg as string = "Ctrl+D to revert to default" edgeprint msg, pCentered, pInfoY, uilook(uiText), dpage SWAP vpage, dpage setvispage vpage dowait LOOP END SUB SUB make_ui_color_editor_menu(m() as string, colors() as integer) m(-1) = "Previous Menu" FOR i as integer = 0 TO uiColorLast m(i) = UiColorCaption(i) & ": " & colors(i) NEXT i END SUB CONST NUM_BOXSTYLE_OPTIONS = 3 SUB ui_boxstyle_editor(palnum as integer) DIM index as integer DIM kind as integer DIM default_boxes(uiBoxLast) as BoxStyle load_master_and_uicol palnum GuessDefaultBoxStyles master(), default_boxes() DIM sample_menu as MenuDef DIM sample_state as MenuState make_ui_editor_sample_menu sample_menu, sample_state DIM color_menu(-1 TO (uiBoxLast + 1) * NUM_BOXSTYLE_OPTIONS - 1) as string make_ui_boxstyle_editor_menu color_menu(), boxlook() DIM state as MenuState state.top = -1 state.pt = -1 state.first = -1 state.last = UBOUND(color_menu) state.autosize = YES state.autosize_ignore_pixels = 18 setkeys DO setwait 55 setkeys IF keyval(ccCancel) > 1 THEN EXIT DO IF keyval(scF1) > 1 THEN show_help "ui_boxstyle_editor" usemenu state mouse_update_hover sample_state index = state.pt \ NUM_BOXSTYLE_OPTIONS kind = state.pt MOD NUM_BOXSTYLE_OPTIONS ' 0=bgcol, 1=edgecol, 2=border WITH boxlook(index) IF enter_space_click(state) THEN IF state.pt = -1 THEN EXIT DO ELSEIF kind = 0 THEN 'Color browser for bgcol .bgcol = color_browser_256(.bgcol) ELSEIF kind = 1 THEN 'Color browser for edgecol .edgecol = color_browser_256(.edgecol) ELSEIF kind = 2 THEN 'Box border browser DIM boxborderb as BoxborderSpriteBrowser .border = boxborderb.browse(.border - 1, YES) + 1 END IF state.need_update = YES END IF IF state.pt >= 0 THEN SELECT CASE kind CASE 0: state.need_update OR= intgrabber(.bgcol, 0, 255) CASE 1: state.need_update OR= intgrabber(.edgecol, 0, 255) CASE 2: state.need_update OR= zintgrabber(.border, -1, gen(genMaxBoxBorder)) END SELECT IF keyval(scCtrl) > 0 ANDALSO keyval(scD) > 1 THEN ' Ctrl+D SELECT CASE kind CASE 0: .bgcol = default_boxes(index).bgcol CASE 1: .edgecol = default_boxes(index).edgecol CASE 2: .border = default_boxes(index).border END SELECT state.need_update = YES END IF END IF END WITH '--update sample according to what you have highlighted sample_menu.boxstyle = 0 sample_state.pt = 0 IF index >= 0 THEN sample_menu.boxstyle = index END IF IF state.need_update THEN state.need_update = NO make_ui_boxstyle_editor_menu color_menu(), boxlook() SaveUIColors uilook(), boxlook(), palnum END IF '--draw screen clearpage dpage IF state.pt >= 0 THEN draw_menu sample_menu, sample_state, dpage standardmenu color_menu(), state, 10, pMenuY, dpage FOR i as integer = large(state.top, 0) TO small(state.top + state.size, state.last) WITH boxlook(i \ NUM_BOXSTYLE_OPTIONS) DIM itemtype as integer = i MOD NUM_BOXSTYLE_OPTIONS IF itemtype = 0 THEN rectangle 0, pMenuY + 9 * (i - state.top), 9, 9, .bgcol, dpage ELSEIF itemtype = 1 THEN rectangle 0, pMenuY + 9 * (i - state.top), 9, 9, .edgecol, dpage END IF END WITH NEXT i DIM msg as string = "Ctrl+D to revert to default" edgeprint msg, pCentered, pInfoY, uilook(uiText), dpage SWAP vpage, dpage setvispage vpage dowait LOOP END SUB SUB make_ui_boxstyle_editor_menu(m() as string, boxes() as BoxStyle) m(-1) = "Previous Menu" FOR i as integer = 0 TO uiBoxLast WITH boxes(i) DIM off as integer = i * NUM_BOXSTYLE_OPTIONS m(off) = "Box style " & i & " color: " & .bgcol m(off + 1) = "Box style " & i & " edge: " & .edgecol m(off + 2) = "Box style " & i & " border image: " & defaultint(.border - 1, "none", -1) END WITH NEXT i END SUB '========================================================================================== FUNCTION RecordPreviewer.getname() as string RETURN "" END FUNCTION 'Ask how to add a new record to an editor. 'what: Type of a record, like "map" 'maxindex: Max valid ID of an existing record 'getname: Returns the name or description of a record, or NULL. Alternative to implementing ' previewer->getname() method, if you already have a function to get the name 'Return value: ' -2 =Cancel ' -1 =New blank ' >=0 =Copy existing 'TODO: this is duplicated in a number of places. Search for 'generic_add_new' FUNCTION generic_add_new (what as string, maxindex as integer, getname as FnRecordName = NULL, previewer as RecordPreviewer ptr = NULL, helpkey as string = "") as integer DIM menu(2) as string DIM whichtocopy as integer = 0 DIM state as MenuState state.last = UBOUND(menu) state.size = 24 DIM menuopts as MenuOptions menuopts.edged = YES menuopts.drawbg = YES state.need_update = YES setkeys YES DO setwait 55 setkeys YES IF keyval(ccCancel) > 1 THEN '--return cancel RETURN -2 END IF IF keyval(scF1) > 1 THEN show_help helpkey usemenu state IF UpdateScreenSlice() THEN state.need_update = YES 'Regenerate preview on window resize IF state.pt = 2 THEN IF intgrabber(whichtocopy, 0, maxindex) THEN state.need_update = YES END IF IF enter_space_click(state) THEN SELECT CASE state.pt CASE 0 ' cancel RETURN -2 CASE 1 ' blank RETURN -1 CASE 2 ' copy RETURN whichtocopy END SELECT END IF IF state.need_update THEN state.need_update = NO IF previewer THEN previewer->update(whichtocopy) DIM name as string name = IIF(getname, getname(whichtocopy), previewer->getname) menu(0) = "Cancel" menu(1) = "New blank " & what menu(2) = "Copy of " & what & " " & whichtocopy & " " & name END IF clearpage vpage IF previewer ANDALSO state.pt = 2 THEN previewer->draw(pRight, pBottom, vpage) edgeprint "Adding new " & what, pCentered, 4, uilook(uiMenuItem), vpage standardmenu menu(), state, 20, 16, vpage, menuopts setvispage vpage dowait LOOP END FUNCTION FUNCTION needaddset (byref pt as integer, byref check as integer, what as string) as bool IF pt <= check THEN RETURN NO IF yesno("Add new " & what & "?") THEN check += 1 RETURN YES ELSE pt -= 1 END IF RETURN NO END FUNCTION 'This is intgrabber, and if the 'more' key is pressed when pt=max, asks whether to 'add a new set. DOES NOT INCREMENT max. Check whether pt > max to see whether this 'needs to be handled. 'maxmax is max value of max, of course FUNCTION intgrabber_with_addset(byref pt as integer, byval min as integer, byval max as integer, byval maxmax as integer=32767, what as string, byval less as KBScancode=ccLeft, byval more as KBScancode=ccRight) as bool IF keyval(more) > 1 AND pt = max AND max < maxmax THEN IF yesno("Add new " & what & "?") THEN pt += 1 RETURN YES END IF RETURN NO ELSE IF less = ccUp ANDALSO more = ccDown THEN 'special case to work around a strange bug in mac os x sprite brows menu arrow key handling RETURN intgrabber(pt, min, max, scComma, scPeriod, NO, NO) ELSE RETURN intgrabber(pt, min, max, less, more, NO, NO) END IF END IF END FUNCTION FUNCTION load_vehicle_name(vehID as integer) as string IF vehID < 0 OR vehID > gen(genMaxVehicle) THEN RETURN "" DIM vehicle as VehicleData LoadVehicle game + ".veh", vehicle, vehID RETURN vehicle.name END FUNCTION FUNCTION load_item_name (it as integer, hidden as integer, offbyone as integer) as string 'it - the item number 'hidden - whether to *not* prefix the item number 'offbyone - whether it is the item number (1), or the itemnumber + 1 (0) IF it <= 0 AND offbyone = NO THEN RETURN "NONE" DIM itn as integer IF offbyone THEN itn = it ELSE itn = it - 1 DIM result as string = readitemname(itn) IF hidden = 0 THEN result = itn & " " & result RETURN result END FUNCTION ' Toggle tagnum between 0 and an unused onetime tag ID (2 to max_onetime) ' This only checks NPC definitions that have already been written ' to disk, so that should be done so before calling SUB onetimetog(byref tagnum as integer) IF tagnum > 0 THEN tagnum = 0 EXIT SUB END IF DIM onetimeusage(max_onetime \ 16) as integer check_used_onetime_npcs onetimeusage() 'Marks bits 0 and 1 as used DIM tried as integer = 0 DIM i as integer = gen(genOneTimeNPC) DO IF i > max_onetime THEN i = 2 IF readbit(onetimeusage(), 0, i) = NO THEN EXIT DO IF tried = max_onetime THEN visible_debug "All onetime usage tags have been used up! Do you really have 16000+ NPCs? This is probably an engine bug!" tagnum = 0 EXIT SUB END IF i += 1 tried += 1 LOOP tagnum = i gen(genOneTimeNPC) = i END SUB LOCAL SUB pal16browse_draw_spriteset(sprite as Frame ptr, pal as Palette16 ptr, x as integer, y as integer, page as integer) IF sprite = NULL THEN EXIT SUB y -= sprite->h \ 2 - 10 FOR framenum as integer = 0 TO sprite->arraylen - 1 frame_draw sprite + framenum, pal, x, y, , page x += (sprite + framenum)->w NEXT END SUB 'Browse for a 16-color palette 'picnum may be -1 FUNCTION pal16browse (curpal as integer, picset as SpriteType, picnum as integer, show_default as bool=NO) as integer DIM sprite as Frame ptr IF picnum >= 0 THEN sprite = frame_load(picset, picnum) DIM ret as integer = pal16browse(curpal, sprite, show_default) frame_unload @sprite RETURN ret END FUNCTION 'Browse for a 16-color palette 'sprite may be NULL FUNCTION pal16browse (curpal as integer, sprite as Frame ptr, show_default as bool=NO) as integer DIM buf(7) as integer REDIM pal16(100) as Palette16 ptr 'At most 100 palettes visible on-screen DIM as integer i, j DIM state as MenuState state.need_update = YES state.top = curpal - 1 state.first = -1 state.spacing = 20 state.autosize = YES IF show_default THEN state.first = -2 END IF DIM stateOpts as MenuOptions stateOpts.itemspacing = 12 '--Find last palette which is not blank loadrecord buf(), game + ".pal", 8, 0 FOR i = buf(1) TO 0 STEP -1 state.last = i loadrecord buf(), game + ".pal", 8, 1 + i FOR j = 0 TO 7 IF buf(j) <> 0 THEN EXIT FOR, FOR NEXT j NEXT i 'We actually want to be able to browse past the last palette to the first empty palette state.last += 1 state.pt = bound(curpal, IIF(show_default, -1, 0), state.last) correct_menu_state state DIM last_size as integer = state.size STATIC pal_clipboard as Palette16 ptr DIM prev_mouse_vis as CursorVisibility = getcursorvisibility() showmousecursor setkeys DO setwait 55 setkeys state.tog = state.tog XOR 1 IF keyval(ccCancel) > 1 THEN EXIT DO IF keyval(scF1) > 1 THEN show_help "pal16browse" calc_menu_rect state, stateOpts, XY(0, 0), dpage IF state.size <> last_size THEN state.need_update = YES last_size = state.size IF usemenu(state) THEN state.need_update = YES 'Copy/paste palette 'Allow Alt+C/V as well as Ctrl+C/V because Alt+C/V copy-pastes the palette 'in the sprite editor IF keyval(scAlt) > 0 ORELSE keyval(scCtrl) > 0 THEN DIM palidx as integer = state.pt - state.top 'Index in pal16 IF state.pt >= 0 ANDALSO palidx <= UBOUND(pal16) ANDALSO pal16(palidx) THEN IF keyval(scC) > 1 THEN 'Ctrl-C copy palette16_unload @pal_clipboard pal_clipboard = palette16_duplicate(pal16(palidx)) END IF IF keyval(scV) > 1 THEN 'Ctrl-V paste IF pal_clipboard THEN palette16_unload @pal16(palidx) pal16(palidx) = palette16_duplicate(pal_clipboard) palette16_save pal16(palidx), state.pt END IF END IF END IF END IF DIM temppt as integer = large(state.pt, 0) IF intgrabber(temppt, 0, state.last, , , , NO) THEN 'use_clipboard=NO state.pt = temppt correct_menu_state state state.need_update = YES END IF IF enter_space_click(state) THEN IF state.pt > state.first THEN curpal = state.pt EXIT DO END IF IF state.need_update THEN state.need_update = NO FOR i = 0 TO state.size palette16_unload @pal16(i) IF state.top + i <= gen(genMaxPal) THEN pal16(i) = palette16_load(state.top + i) NEXT i END IF '--Draw screen clearpage dpage FOR i = 0 TO state.size DIM sloty as integer = i * 20 DIM cur_slot as integer = state.top + i IF cur_slot > state.last THEN CONTINUE FOR textcolor uilook(uiMenuItem), 0 IF cur_slot = state.hover THEN textcolor uilook(uiMouseHoverItem), 0 IF cur_slot = state.pt THEN textcolor uilook(uiSelectedItem + state.tog), 0 SELECT CASE cur_slot CASE state.first 'Might be -1 or -2 depending on whether default is displayed printstr "Cancel", 4, 5 + sloty, dpage CASE -1 printstr "Default", 4, 5 + sloty, dpage CASE IS >= 0 DIM txtwidth as integer = 8 + large(16, textwidth(STR(cur_slot))) DIM border_col as integer IF cur_slot = state.hover THEN border_col = uilook(uiMouseHoverItem) IF cur_slot = state.pt THEN border_col = uilook(uiMenuitem) edgebox txtwidth - 1, 1 + sloty, 114, 18, uilook(uiBackground), border_col, dpage FOR j = 0 TO 15 IF pal16(i) THEN DIM c as integer = pal16(i)->col(j) rectangle txtwidth + j * 7, 2 + sloty, 5, 16, c, dpage END IF NEXT j IF cur_slot <> state.pt THEN IF pal16(i) THEN pal16browse_draw_spriteset sprite, pal16(i), txtwidth + 150, sloty, dpage END IF END IF printstr STR(cur_slot), 4, 5 + sloty, dpage END SELECT NEXT i IF state.pt >= 0 THEN '--draw the selected spriteset last, on top of all others i = state.pt - state.top DIM sloty as integer = i * 20 DIM txtwidth as integer = 8 + large(16, textwidth(STR(state.pt))) IF pal16(i) THEN pal16browse_draw_spriteset sprite, pal16(i), txtwidth + 125, sloty, dpage END IF END IF SWAP vpage, dpage setvispage vpage dowait LOOP setcursorvisibility(prev_mouse_vis) setkeys FOR i = 0 TO 100 palette16_unload @pal16(i) NEXT RETURN curpal END FUNCTION ' Number of steps before a random formation triggers FUNCTION formset_step_estimate(freq as integer, suffix as string=" steps") as string IF freq = 0 THEN RETURN "never" ' Round upwards to get the actual number steps required ' gam.random_battle_countdown initialised to range(100, 60), which is 40-160. DIM low_est as integer = (40 + freq - 1) \ freq DIM high_est as integer = (160 + freq - 1) \ freq ' This average is just an estimate. Add 0.5 due to the rounding-up behaviour. DIM average as double = 100 / freq + 0.5 RETURN low_est & "-" & high_est & suffix & ", avg~" & format(average, "0.0") END FUNCTION 'Average chance to trigger a formation set on each step FUNCTION formset_freq_estimate(freq as integer) as double IF freq = 0 THEN RETURN 0. DIM average_steps as double = 100 / freq + 0.5 'See formset_step_estimate RETURN 1. / average_steps END FUNCTION 'Explanation for a hero/enemy's speed stat. Only applies to active-time mode FUNCTION speed_estimate(speed as integer) as string IF speed = 0 THEN RETURN "never gets a turn" DIM ticks as integer = CEIL(1000 / speed) DIM result as string = strprintf("%.3g", ticks / ideal_ticks_per_second()) RETURN "1 turn every " & result & " sec" END FUNCTION FUNCTION seconds_estimate(ticks as integer) as string IF ticks = 0 THEN RETURN "0" RETURN strprintf("%.2f", ticks / ideal_ticks_per_second()) END FUNCTION 'Return a caption shown in hero and enemy editors while editing a stat. FUNCTION stat_value_caption(statnum as integer, statval as integer) as string IF statnum = statSpeed ANDALSO gen(genBattleMode) = 0 THEN 'Active-time RETURN speed_estimate(statval) ELSEIF statnum = statFocus ANDALSO prefbit(52) = NO THEN '"Ignore MP~ stat" RETURN "Attacks cost " & (100 - statval) & "% normal MP cost" ELSEIF statnum = statHitX ANDALSO prefbit(29) = NO THEN '"Ignore extra Hits 'stat" Total number of hits is capped to 20. Increasing Hits above 20 will 'continue to increase the average number of hits, but we don't show that. RETURN "Attacks hit 0 to " & small(19, statval) & " extra times" ELSEIF statnum >= statCtr THEN 'Ctr, possible Hits and Focus (MP~) RETURN "Free stat for any use" END IF END FUNCTION SUB draw_crosshairs(pos as XYPair, length as integer = 1, zoom as integer = 1, col as integer, page as integer) rectangle pos.x - length * zoom, pos.y, length * zoom, zoom, col, page rectangle pos.x + zoom, pos.y, length * zoom, zoom, col, page rectangle pos.x, pos.y - length * zoom, zoom, length * zoom, col, page rectangle pos.x, pos.y + zoom, zoom, length * zoom, col, page END SUB 'Editor for an x/y position. 'Note that this does not center or scale the slice, unlike xy_position_on_sprite 'UNUSED SUB xy_position_on_slice (sl as Slice Ptr, byref x as integer, byref y as integer, caption as string, helpkey as string) DIM col as integer DIM tog as integer DIM root as Slice Ptr setkeys DO setwait 55 setkeys tog = tog XOR 1 IF keyval(ccCancel) > 1 THEN EXIT DO IF keyval(scF1) > 1 THEN show_help helpkey IF enter_or_space() THEN EXIT DO IF keyval(ccLeft) > 0 THEN x -= 1 IF keyval(ccRight) > 0 THEN x += 1 IF keyval(ccUp) > 0 THEN y -= 1 IF keyval(ccDown) > 0 THEN y += 1 DIM dragging as bool = readmouse.drag_dist > 3 IF readmouse.buttons AND mouseRight ORELSE dragging THEN DIM temp as XYPair = readmouse.pos - sl->ScreenPos x = temp.x y = temp.y END IF IF enter_or_space() ORELSE (readmouse.release AND mouseLeft ANDALSO dragging = NO) THEN EXIT DO clearpage dpage DrawSlice sl, dpage col = uilook(uiBackground) IF tog = 0 THEN col = uilook(uiSelectedItem) draw_crosshairs sl->ScreenPos + XY(x, y), 1, 1, col, dpage wrapprint caption, pCentered, 0, uilook(uiText), dpage edgeprint "Position point and press Enter, Space, or Click", 0, pBottom, uilook(uiText), dpage edgeprint XY(x, y), 0, pBottom - 10, uilook(uiText), dpage SWAP vpage, dpage setvispage vpage dowait LOOP END SUB 'xy_position_on_sprite is a bit nicer than xy_position_on_slice, so this wrapper lets you use 'the former with a sprite slice SUB xy_position_on_sprite_slice (sl as Slice Ptr, byref x as integer, byref y as integer, caption as string, helpkey as string) BUG_IF(sl->SliceType <> slSprite, "expected sprite slice") WITH *sl->SpriteData IF .img.sprite THEN xy_position_on_sprite .img, x, y, .frame, caption, helpkey END IF END WITH END SUB SUB xy_position_on_sprite (spr as GraphicPair, byref x as integer, byref y as integer, byval frame as integer, caption as string, helpkey as string) DIM col as integer DIM tog as integer DIM fr as Frame ptr = spr.sprite + frame setkeys DO setwait 55 setkeys tog = tog XOR 1 'Draw the sprite at 2x zoom unless that won't fit on-screen DIM zoom as integer = 1 IF fr->size * 2 < get_resolution() - XY(5, 30) THEN zoom = 2 DIM topleft as XYPair topleft = (get_resolution() - fr->size * zoom) \ 2 IF keyval(ccCancel) > 1 THEN EXIT DO IF keyval(scF1) > 1 THEN show_help helpkey DIM speed as integer = IIF(keyval(scShift), 4, 1) IF slowkey(ccLeft, 100) THEN x -= speed IF slowkey(ccRight, 100) THEN x += speed IF slowkey(ccUp, 100) THEN y -= speed IF slowkey(ccDown, 100) THEN y += speed DIM dragging as bool = readmouse.drag_dist > 3 IF readmouse.buttons AND mouseRight ORELSE dragging THEN DIM temp as XYPair temp = (readmouse.pos - topleft) \ zoom x = temp.x y = temp.y END IF IF enter_or_space() ORELSE (readmouse.release AND mouseLeft ANDALSO dragging = NO) THEN EXIT DO clearpage dpage drawbox topleft.x - 1, topleft.y - 1, fr->w * zoom + 2, fr->h * zoom + 2, uilook(uiSelectedDisabled), 1, dpage frame_draw fr, spr.pal, topleft.x, topleft.y, , dpage, DrawOptions(zoom) col = uilook(uiBackground) IF tog = 0 THEN col = uilook(uiSelectedItem) draw_crosshairs topleft + XY(x, y) * zoom, 1, zoom, col, dpage wrapprint caption, pCentered, pMenuY, uilook(uiText), dpage wrapprint XY(x, y) & !" Shift for speed\nPosition point and press Enter, Space, or Click", pInfoX, pInfoY, uilook(uiText), dpage SWAP vpage, dpage setvispage vpage dowait LOOP END SUB 'Clear a videopage with a dark textured background which is used to indicate the size of the game's window SUB draw_textured_background(page as integer) clearpage page fuzzyrect 0, 0, , , uilook(uiDisabledItem), page, 7 END SUB 'Create a videopage equal in size to the game's resolution FUNCTION gameres_page() as integer 'TODO: possibly we could use the game's bitdepth (gen32bitMode) for this page, 'but that may not work and probably isn't needed DIM ret as integer = allocatepage(gen(genResolutionX), gen(genResolutionY)) vpages(ret)->noresize = YES RETURN ret END FUNCTION 'Draw a videopage at (by default) the bottom-right corner of destpage. 'Typically used to draw a gameres_page, for previewing in-game graphics. 'See also draw_textured_background SUB draw_viewport_page(srcpage as integer, destpage as integer, byval where as RelPosXY = XY(pRight, pBottom)) DIM pagesize as XYPair = vpages(srcpage)->size rectangle where.x + 1, where.y + 1, pagesize.w + 2, pagesize.h + 2, findrgb(80, 80, 80), destpage frame_draw vpages(srcpage), , where.x + showLeft, where.y + showTop, NO, destpage END SUB 'Returns "prefix ABS(n) suffix [AUTOSET] ()" 'where everything except the ABS(n) is optional. LOCAL FUNCTION base_tag_caption(byval n as integer, prefix as string, suffix as string, zerocap as string, onecap as string, negonecap as string, byval allowspecial as bool) as string DIM ret as string ret = prefix IF LEN(ret) > 0 THEN ret &= " " ret &= ABS(n) & suffix IF allowspecial <> YES ANDALSO tag_is_autoset(n) THEN ret &= " [AUTOSET]" 'Append " ($cap)" DIM cap as string cap = load_tag_name(n) IF n = 0 AND LEN(zerocap) > 0 THEN cap = zerocap IF n = 1 AND LEN(onecap) > 0 THEN cap = onecap IF n = -1 AND LEN(negonecap) > 0 THEN cap = negonecap cap = TRIM(cap) IF LEN(cap) > 0 THEN ret &= " (" & cap & ")" RETURN ret END FUNCTION FUNCTION tag_toggle_caption(byval n as integer, prefix as string="Toggle tag", byval allowspecial as bool=NO) as string RETURN base_tag_caption(n, prefix, "", "N/A", "Unchangeable", "Unchangeable", allowspecial) END FUNCTION FUNCTION tag_choice_caption(byval n as integer, prefix as string="", byval allowspecial as bool=NO) as string RETURN base_tag_caption(n, prefix, "", "None", "Unchangeable", "Unchangeable", allowspecial) END FUNCTION FUNCTION tag_set_caption(byval n as integer, prefix as string="Set Tag", byval allowspecial as bool=NO) as string RETURN base_tag_caption(n, prefix, "=" & onoroff(n), "No tag set", "Unchangeable", "Unchangeable", allowspecial) END FUNCTION ' Note that this similar to textbox_condition[_short]_caption and describe_tag_condition. Sorry! FUNCTION tag_condition_caption(byval n as integer, prefix as string="Tag", zerocap as string, onecap as string="Never", negonecap as string="Always") as string RETURN base_tag_caption(n, prefix, "=" & onoroff(n), zerocap, onecap, negonecap, YES) END FUNCTION 'Describe a condition which checks two tags (both conditions need to pass) 'zerovalue: meaning of 0. true is always, false is never FUNCTION describe_two_tag_condition(prefix as string, truetext as string, falsetext as string, byval zerovalue as bool, byval tag1 as integer, byval tag2 as integer) as string DIM ret as string = prefix DIM true_count as integer DIM false_count as integer IF tag1 = 0 THEN tag1 = IIF(zerovalue, -1, 1) IF tag2 = 0 THEN tag2 = IIF(zerovalue, -1, 1) IF tag1 = 1 THEN false_count += 1 ELSEIF tag1 = -1 THEN true_count += 1 ELSE ret &= " tag " & ABS(tag1) & " = " & onoroff(tag1) END IF IF tag2 = 1 THEN false_count += 1 ELSEIF tag2 = -1 THEN true_count += 1 ELSE IF true_count = 0 AND false_count = 0 THEN ret &= " and" ret &= " tag " & ABS(tag2) & " = " & onoroff(tag2) END IF IF true_count = 2 THEN ret = truetext IF false_count > 0 THEN ret = falsetext RETURN ret END FUNCTION 'Get a list of the first letters (lowercase) of every word in each menu() string, except 'those words listed in excludewords. excludewords should be a space-separated 'list (case matters). 'menukeys() should be statically sized. SUB get_menu_hotkeys (menu() as string, byval menumax as integer, menukeys() as string, excludewords as string = "") 'Easy exercise for the reader: Write this in three lines of Python DIM excludes() as string IF excludewords = "" THEN REDIM excludes(-1 TO -1) ELSE split excludewords, excludes(), " " END IF FOR i as integer = 0 TO menumax menukeys(i) = "" DIM firstletter as bool = YES FOR j as integer = 1 TO LEN(menu(i)) DIM isalp as integer = isalpha(menu(i)[j - 1]) IF firstletter ANDALSO isalp THEN DIM excluded as bool = NO FOR k as integer = 0 TO UBOUND(excludes) IF MID(menu(i), j, LEN(excludes(k))) = excludes(k) THEN excluded = YES : EXIT FOR NEXT IF excluded = NO THEN menukeys(i) += LCASE(MID(menu(i), j, 1)) END IF END IF firstletter = (isalp = 0) NEXT 'debug "hotkeys from '" & menu(i) & "' -> '" & menukeys(i) & "'" NEXT END SUB FUNCTION experience_chart (byval expcurve as double=0.2) as double 'DIM exp_first_level as integer = 30 'DIM exp_adder as integer = 5 'DIM exp_uppercap as integer = 1000000 DIM mode as integer = 0 STATIC hero_count as integer = 4 STATIC enemy_id as integer = 0 DIM enemy as EnemyDef STATIC form_id as integer = 0 DIM startfrom as integer = 4 DIM menu(startfrom + gen(genMaxLevel)) as string menu(0) = "Previous menu..." DIM state as MenuState WITH state .autosize = YES .last = UBOUND(menu) .need_update = YES END WITH STATIC first_view as bool = YES setkeys DO setwait 55 setkeys IF keyval(ccCancel) > 1 THEN EXIT DO IF keyval(scF1) > 1 THEN show_help "experience_chart" usemenu state IF enter_space_click(state) THEN IF state.pt = 0 THEN EXIT DO END IF IF state.pt = 1 THEN DIM curve_int as integer = expcurve * 100 IF intgrabber(curve_int, 0, 100) THEN expcurve = curve_int / 100 state.need_update = YES END IF END IF IF state.pt = 2 THEN IF intgrabber(mode, 0, 2) THEN state.need_update = YES END IF IF state.pt = 3 THEN IF mode = 1 THEN IF enemygrabber(enemy_id, state) THEN state.need_update = YES ELSEIF mode = 2 THEN IF intgrabber(form_id, 0, gen(genMaxFormation)) THEN state.need_update = YES END IF END IF IF state.pt = 4 THEN IF intgrabber(hero_count, 1, 4) THEN state.need_update = YES END IF IF state.need_update THEN DIM test_exp as integer = 0 DIM test_name as string menu(1) = "Experience curve: " & format(expcurve, "0.00") IF mode = 0 THEN menu(2) = "Preview mode: Total Exp." menu(3) = "Compared to N/A" ELSEIF mode = 1 THEN loadenemydata enemy, enemy_id menu(2) = "Preview mode: Enemy" menu(3) = "Compared to enemy: " & enemy_id & " " & enemy.name & " (" & enemy.reward.exper & " exp)" test_exp = enemy.reward.exper test_name = enemy.name ELSEIF mode = 2 THEN DIM form as Formation LoadFormation form, form_id test_exp = 0 FOR i as integer = 0 TO 7 IF form.slots(i).id >= 0 THEN loadenemydata enemy, form.slots(i).id test_exp += enemy.reward.exper END IF NEXT i menu(2) = "Compare mode: Formation" menu(3) = "Compared to formation: " & form_id & " (" & test_exp & " exp)" test_name = "Formation" & form_id END IF menu(4) = "Distributed to a party of: " & hero_count & " heroes" DIM suffix as string DIM killcount as string FOR lev as integer = 1 TO gen(genMaxLevel) IF mode = 0 THEN suffix = "total " & total_exp_to_level(lev, expcurve) ELSE IF test_exp > 0 THEN killcount = STR(ceiling(exptolevel(lev, expcurve) / test_exp * hero_count)) ELSE killcount = "infinite" END IF suffix = "= " & test_name & "*" & killcount END IF menu(startfrom + lev) = "Level " & lev & " +" & exptolevel(lev, expcurve) & " " & suffix NEXT lev state.need_update = NO END IF clearpage vpage standardmenu menu(), state, , , vpage setvispage vpage dowait IF first_view THEN first_view = NO 'notification "This screen is informational only. You cannot customize the experience formula yet." END IF LOOP RETURN expcurve END FUNCTION SUB stat_growth_chart () 'midpoint should stored in gen() DIM midpoint as double = 0.3219 'default to current DIM midpoint_repr as string = format_percent(midpoint, 4) DIM menu(2) as string menu(0) = "Previous menu..." DIM state as MenuState WITH state .size = 24 .last = UBOUND(menu) .need_update = YES END WITH DIM preview_lev as integer = gen(genMaxLevel) \ 2 'Position and size of the graph DIM rect as RectType rect.x = 150 rect.y = 40 rect.wide = 150 rect.high = 140 DIM origin_y as integer = rect.y + rect.high setkeys YES DO setwait 55 setkeys YES IF keyval(ccCancel) > 1 THEN EXIT DO IF keyval(scF1) > 1 THEN show_help "stat_growth" usemenu state IF enter_space_click(state) THEN IF state.pt = 0 THEN EXIT DO END IF IF state.pt = 1 THEN state.need_update OR= percent_grabber(midpoint, midpoint_repr, -0.1, 1.2, 4) ELSEIF state.pt = 2 THEN state.need_update OR= intgrabber(preview_lev, 0, gen(genMaxLevel)) END IF IF state.need_update THEN menu(1) = "Fix value at level " & (gen(genMaxLevel) / 2) & " : " & midpoint_repr menu(2) = "Preview: at level " & preview_lev & " = " & format_percent(atlevel_quadratic(preview_lev, 0, 1000000, midpoint) / 1000000, 4) ' of Level" & gen(genMaxLevel) & " value" state.need_update = NO END IF 'Draw screen clearpage vpage standardmenu menu(), state, , , vpage 'Draw a 150x150 graph 'axes drawline rect.x, origin_y, rect.x, rect.y, uilook(uiDisabledItem), vpage drawline rect.x, origin_y, rect.x + rect.wide, origin_y, uilook(uiDisabledItem), vpage 'line (drawn so that if genMaxLevel is small, you get a lot of steps, and never sloped line segments) DIM lasty as double FOR x as integer = 0 TO rect.wide - 1 DIM lev as integer = INT((gen(genMaxLevel) + 1) * x / rect.wide) 'floor DIM y as double = atlevel_quadratic(lev, 0, rect.high * 100, midpoint) / 100 IF x = 0 THEN lasty = y drawline x + rect.x, origin_y - y, x + rect.x, origin_y - lasty, uilook(uiHighlight), vpage lasty = y NEXT 'Draw crosshair DIM crosshair_lev as double IF state.pt = 2 THEN crosshair_lev = preview_lev ELSE crosshair_lev = gen(genMaxLevel) / 2 DIM as double crosshairx, crosshairy 'in pixels crosshairx = rect.wide * crosshair_lev / gen(genMaxLevel) crosshairy = atlevel_quadratic(crosshair_lev, 0, rect.high * 100, midpoint) / 100 drawline rect.x + crosshairx - 3, origin_y - crosshairy, rect.x + crosshairx + 3, origin_y - crosshairy, uilook(uiHighlight2), vpage drawline rect.x + crosshairx, origin_y - crosshairy - 3, rect.x + crosshairx, origin_y - crosshairy + 3, uilook(uiHighlight2), vpage setvispage vpage dowait LOOP END SUB #ifndef NO_TEST_GAME FUNCTION pick_channel_name() as string #ifdef __FB_WIN32__ #ifdef UWP 'This pipe name is mandatory in UWP. (Should it actually be \.\pipe\LOCAL? Looks like a typo in the docs) 'Note, named pipes can only be used between apps in the same container. return "\\.\pipe\LOCAL" #else return "\\.\pipe\ohrrpgce_lump_updates_testing_" & randint(100000) #endif #else return tmpdir + ".lump_updates.txt" #endif END FUNCTION ' Used by spawn_game to run gdb or valgrind. ' Returns a ProcessHandle or NO/NULL. ' Note: these's a fair bit of overlap between this and spawn_and_wait for Unix, ' but this is specific to spawn_game. spawn_and_wait has better error checking. FUNCTION spawn_console_process(executable as string, args as string, title as string) as ProcessHandle #IFDEF __FB_UNIX__ ' On Unix the spawned program doesn't inherit access to our tty even if we have one, ' so need to spawn it inside an xterm ' TODO: this isn't going to work on OSX... unless maybe you have X11 installed? ' Wrap in a xterm call... DIM xtermargs as string xtermargs = " -geometry 120x30 -bg black -fg gray90 -title '" & title & "' -e " _ """" & escape_string(executable & " " & args, """\") & """" executable = find_helper_app("xterm") IF LEN(executable) = 0 THEN notification "xterm is missing; can't continue" RETURN NO END IF waitforkeyrelease RETURN open_process(executable, xtermargs, YES, YES) #ELSEIF defined(__FB_WIN32__) waitforkeyrelease ' Likewise on Windows need a console. But this is only implemented on Windows RETURN open_console_process(executable, args) #ELSE notification "Running under GDB/Valgrind not implemented on this platform" RETURN NO #ENDIF END FUNCTION 'Spawn Game for live-previewing. Puts the ProcessHandle in Game_process. 'At most one of gdb and valgrind should be true. 'Returns true if successfully spawned FUNCTION spawn_game(gdb as bool = NO, valgrind as bool = NO) as bool IF Game_process <> 0 THEN 'First clean up after the last time we ran Game cleanup_process @Game_process END IF DIM channel_name as string channel_name = pick_channel_name() IF channel_open_server(channel_to_Game, channel_name) = NO THEN notification "Couldn't open channel to communicate with Game" RETURN NO END IF debuginfo "Successfully opened IPC channel " + channel_name DIM gameexename as string = GAMEEXE DIM executable as string #ifdef __FB_DARWIN__ gameexename = "OHRRPGCE-Game" CONST mac_game_bundle as string = "OHRRPGCE-Game.app/Contents/MacOS/ohrrpgce-game" ' First check for unbundled ohrrpgce-game (because it would be most recently built) then OHRRPGCE-Game.app executable = app_dir & GAMEEXE IF isfile(executable) = NO THEN executable = app_dir & mac_game_bundle IF isfile(executable) = NO THEN 'If Gatekeeper Path Randomization (aka App Translocation) happens, then EXEPATH (and app_dir) 'is a random path inside a read-only mount. 'So also check CURDIR, which is the location of the .rpg, or a global location executable = CURDIR & SLASH & mac_game_bundle IF isfile(executable) = NO THEN executable = ENVIRON("HOME") & "/Applications/" & mac_game_bundle IF isfile(executable) = NO THEN executable = "/Applications/" & mac_game_bundle END IF END IF END IF END IF #else executable = app_dir & GAMEEXE #endif IF isfile(executable) = NO THEN #ifdef __FB_DARWIN__ IF INSTR(EXEPATH, "AppTranslocation") THEN 'Gatekeeper Path Randomization is active. Also possible to detect with an API, or by 'checking whether the filesystem is read-only using statfs(). visible_debug !"It appears that macOS's Gatekeeper has quarantined OHRRPGCE-Custom, so it can't access OHRRPGCE-Game. To disable the quarantine, move OHRRPGCE-Custom to a different folder using Finder (you can move it back afterwards), and make sure you're not running it from inside your Downloads folder or from inside the DMG you downloaded.\nAlternatively, place OHRRPGCE-Game next to the .rpg file or in your user or global Applications folder." RETURN NO END IF #endif visible_debug "Couldn't find " & gameexename & !"\nIt should be in the same directory as " & CUSTOMEXE RETURN NO END IF DIM arguments as string #IFDEF __FB_UNIX__ arguments = "--from_Custom " & escape_filename(channel_name) #ELSE 'Not a filename arguments = "--from_Custom " & channel_name #ENDIF IF gdb THEN ' Wrap in a gdb call... ' channel_wait_for_client_connection is going to time out if Game isn't ' allowed to run normally, so as a compromise break immediately after the ' channel is connected. (You can always increase the 3sec delay instead.) ' We imitate invoking gdb via the gdbgame.sh/bat script DIM cmdfile as string cmdfile = finddatafile("misc/gdbcmds1.txt", NO) DIM gdbargs as string IF LEN(cmdfile) > 0 THEN gdbargs = "-x=""" & cmdfile & """ " ' gdbcmds1.txt adds breakpoints to catch -exx fatal errors, so disable the -exx handler arguments &= " --rawexx" ELSE gdbargs = "-ex ""break HOOK_AFTER_ATTACH_TO_CUSTOM"" -ex ""run"" " END IF gdbargs &= "--args " & executable & " " & arguments executable = find_helper_app("gdb") IF LEN(executable) = 0 THEN notification "Couldn't find gdb. You need to install it." RETURN NO END IF Game_process = spawn_console_process(executable, gdbargs, "gdb " & GAMEEXE) ELSEIF valgrind THEN #IFNDEF __FB_UNIX__ notification "Valgrind only supported on UNIX" RETURN NO #ENDIF executable = find_helper_app("valgame.sh") IF LEN(executable) = 0 THEN notification "Couldn't find valgame.sh. You should run from a copy of the source code." RETURN NO END IF Game_process = spawn_console_process(executable, arguments, "valgame.sh") ELSE waitforkeyrelease Game_process = open_process(executable, arguments, YES, YES) END IF 'debuginfo "Spawning: " & executable & " " & arguments IF Game_process = 0 THEN notification "Couldn't run " & gameexename RETURN NO END IF 'We currently do nothing at all with Game_process except cleanup (on Unix 'closing the channel freezes until the child finishes). 'Instead we test Game is still running by checking channel_to_Game 'Need Game to connect before we can safely write to the pipe; wait up to 3000ms DIM waitms as integer = IIF(valgrind, 9000, IIF(gdb, 5000, 3000)) IF channel_wait_for_client_connection(channel_to_Game, waitms) = 0 THEN notification "Error communicating with " & gameexename & !" (couldn't connect); aborting\n(Press a key)" channel_close channel_to_Game cleanup_process @Game_process RETURN NO END IF 'Write version info DIM tmp as string 'msgtype magickey,proto_ver,program_ver,version_string tmp = "V OHRRPGCE," & CURRENT_TESTING_IPC_VERSION & "," & version_revision & "," & short_version channel_write_line(channel_to_Game, tmp) tmp = "G " & sourcerpg channel_write_line(channel_to_Game, tmp) tmp = "W " & workingdir channel_write_line(channel_to_Game, tmp) 'If any of these writes fails, channel_to_Game is closed IF channel_to_Game THEN 'If we got this far, start sending lump updates and locking files before writing set_OPEN_hook @inworkingdir, YES, YES, @channel_to_Game ELSE notification "Error communicating with " & gameexename & !" (channel write failure); aborting\n(Press a key)" channel_close channel_to_Game cleanup_process @Game_process RETURN NO END IF RETURN YES END FUNCTION SUB spawn_game_menu(gdb as bool = NO, valgrind as bool = NO) #IFDEF __FB_WIN32__ IF is_windows_9x() THEN notification "Testing your game while editing isn't supported on your version of Windows; it requires an NT-based Windows release" EXIT SUB END IF #ENDIF 'Prod the channel to see whether it's still up (send ping) channel_write_line(channel_to_Game, "P ") DIM scancode as integer IF channel_to_Game THEN scancode = notification(!"The game is already running! You can't run multiple test copies of a game, but any edits you make will take effect without restarting.\n" _ "Press F1 to see the help file for Test Game.") ELSE gen(genCurrentDebugMode) = 1 xbsave game + ".gen", gen(), 1000 IF spawn_game(gdb, valgrind) THEN scancode = notification( "You're running your game in live preview mode. " _ !"Press F1 now to read the help file for this if you haven't already.\n\n" _ "Press any key") END IF END IF IF scancode = scF1 THEN show_help "test_game" END SUB #endif 'Update and save edit_time and last_saved for the game SUB save_edit_time () DIM gen_root as NodePtr = get_general_reld() DIM timenode as NodePtr = GetOrCreateChild(gen_root, "edit_time") SetContent(timenode, GetFloat(timenode) + active_seconds - last_active_seconds) debuginfo "Saving - been editing for " & format_duration(active_seconds - last_active_seconds, 0) SetChildNodeDate(gen_root, "last_saved", NOW) last_active_seconds = active_seconds write_general_reld() END SUB 'Return true on success FUNCTION save_current_game (byval genDebugMode_override as integer=-1) as bool 'Apply the appropriate genCurrentDebugMode IF genDebugMode_override >= 0 THEN gen(genCurrentDebugMode) = genDebugMode_override ELSE gen(genCurrentDebugMode) = gen(genDebugMode) END IF xbsave game + ".gen", gen(), 1000 save_edit_time basic_textbox "LUMPING DATA: please wait.", , vpage, , , YES 'shrink setvispage vpage, NO '--verify various stuff rpg_sanity_checks clearpage vpage '--lump data to SAVE rpg file IF write_rpg_or_rpgdir(workingdir, sourcerpg) THEN write_session_info 'Update sourcerpg mtime and reset editing start time IF automatic_backup(sourcerpg) = NO THEN visible_debug "Successfully saved the game, but couldn't write automatic backup to autobackups folder" ELSE show_overlay_message "Saved, and copied to autobackups", 1.1 END IF RETURN YES END IF END FUNCTION 'Returns true on success FUNCTION automatic_backup (rpgfile as string) as bool DIM keep_how_many as integer = 10 'FIXME: this could be customized per-game DIM backupdir as string = trimfilename(rpgfile) & SLASH & "autobackups" IF NOT isdir(backupdir) THEN makedir backupdir IF NOT diriswriteable(backupdir) THEN debug "Can't do automatic backups: """ & backupdir & """ is not writeable" RETURN NO END IF DIM warnfile as string = backupdir & SLASH & "README-WARNING-automatic-backups.txt" IF NOT isfile(warnfile) THEN string_to_file !"This folder contains automatic backups. A copy of your game is made here\n" _ "every time that CUSTOM saves a game. At most 10 copies per game are kept.\n" _ "WARNING! These automatic backups are no substitute for manual backups!\n" _ "They are just here to save your slime that one time you forget!\n\n" _ "If you don't have any other backup plan, at least remember to e-mail\n" _ "a copy of your .rpg file to yourself once a week.", warnfile END IF 'Copy the backup DIM datestr as string = MID(DATE, 7, 4) & "-" & MID(DATE, 1, 2) & "-" & MID(DATE, 4, 2) DIM destfile as string = backupdir & SLASH & trimextension(trimpath(rpgfile)) & "-" & datestr & "." & justextension(rpgfile) DIM ret as bool IF isdir(rpgfile) THEN IF isdir(destfile) THEN killdir destfile ret = confirmed_copydirectory(rpgfile, destfile) ELSE ret = writeablecopyfile(rpgfile, destfile) END IF IF ret = NO THEN RETURN NO 'Cull old backups to avoid bloatclutter. REDIM oldfiles() as string findfiles backupdir, trimextension(trimpath(rpgfile)) & "-*.rpg", fileTypeFile, , oldfiles() REDIM olddirs() as string findfiles backupdir, trimextension(trimpath(rpgfile)) & "-*.rpgdir", fileTypeDirectory, , olddirs() DIM old as string vector v_new old FOR i as integer = 0 TO UBOUND(oldfiles) IF LEN(oldfiles(i)) THEN v_append old, oldfiles(i) NEXT i FOR i as integer = 0 TO UBOUND(olddirs) IF LEN(olddirs(i)) THEN v_append old, olddirs(i) NEXT i v_sort old DIM oldrpg as string FOR i as integer = v_len(old) - 1 - keep_how_many TO 0 STEP -1 oldrpg = backupdir & SLASH & old[i] IF isdir(oldrpg) THEN killdir oldrpg ELSE safekill oldrpg END IF NEXT i v_free old RETURN YES END FUNCTION ' Save all lumps in lumpsdir, except for *.tmp, as a lumped file or .rpgdir directory. ' Returns true on success. ' Note that this can set cleanup_workingdir_on_exit=NO on failure. FUNCTION write_rpg_or_rpgdir (lumpsdir as string, filetolump as string) as bool '--build the list of files to lump. We don't need hidden files DIM filelist() as string findfiles lumpsdir, ALLFILES, fileTypeFile, NO, filelist() 'Removes .tmp files fixlumporder filelist() IF isdir(filetolump) THEN '---copy changed files back to source rpgdir--- IF NOT fileiswriteable(filetolump & SLASH & "archinym.lmp") THEN move_unwriteable_rpg filetolump makedir filetolump END IF FOR i as integer = 0 TO UBOUND(filelist) safekill filetolump + SLASH + filelist(i) IF writeablecopyfile(lumpsdir + SLASH + filelist(i), filetolump + SLASH + filelist(i)) = NO THEN pop_warning "Failed to save game to " & filetolump & LINE_END "Look in c_debug.txt for error messages." RETURN NO END IF 'Moving files instead would offer no failsafe if something goes wrong while moving '(Plus can't move from different mounted filesystem) NEXT ELSE '---relump data into lumpfile package--- IF NOT fileiswriteable(filetolump) THEN move_unwriteable_rpg filetolump END IF DIM errmsg as string = lumpfiles(filelist(), filetolump, lumpsdir + SLASH) IF lumpsdir = workingdir THEN cleanup_workingdir_on_exit = (LEN(errmsg) = 0) 'Don't delete workingdir if it hasn't been saved END IF IF LEN(errmsg) THEN 'Show a warning instead of a fatal error: this isn't fatal pop_warning "Failed to save game to " & filetolump & ": " & errmsg RETURN NO END IF END IF RETURN YES END FUNCTION SUB move_unwriteable_rpg (filetolump as string) clearpage vpage DIM newfile as string = documents_dir & SLASH & trimpath(filetolump) visible_debug filetolump + " is not writeable. Saving to " + newfile filetolump = newfile END SUB SUB check_used_onetime_npcs(bits() as integer) 'Search through all the NPC definitions and figure out which NPC onetime ' bits have been used. The result is a bitset array with 0 bits for unused ' onetimes and 1 bits for used onetimes. flusharray bits() setbit bits(), 0, 0, YES ' bit 0 can't be used, 0 indicates usable repeatedly. setbit bits(), 0, 1, YES ' bit 1 can't be used, because istag() doesn't allow tag 1. REDIM npcdata(0) as NPCType FOR m as integer = 0 TO gen(genMaxMap) LoadNPCD maplumpname(m, "n"), npcdata() check_used_onetime_npcs_npcdata bits(), npcdata(), m NEXT m LoadNPCD global_npcdef_filename(1), npcdata(), NO 'expect_exists=NO check_used_onetime_npcs_npcdata bits(), npcdata(), -1 END SUB SUB check_used_onetime_npcs_npcdata(bits() as integer, npcdata() as NPCType, mapnum as integer) FOR i as integer = 0 TO UBOUND(npcdata) WITH npcdata(i) IF .usetag > max_onetime THEN debugerror "out-of-range onetime tag " & .usetag & " for NPC " & i & IIF(mapnum >= 0, " on map " & mapnum, " global pool " & ABS(mapnum)) ELSEIF .usetag > 0 THEN IF readbit(bits(), 0, .usetag) THEN 'Don't show a warning for duplicate onetime tags because it happens all the time with map copying END IF setbit bits(), 0, .usetag, YES END IF END WITH NEXT i END SUB SUB menu_of_reorderable_nodes(st as MenuState, menu as MenuDef) 'This is intended for menus that represent sibling Nodes. The NodePtr 'is in the .dataptr of the selected menu item WITH *menu.items[st.pt] IF .dataptr <> 0 THEN DIM node as NodePtr = .dataptr IF reorderable_node(node) THEN st.need_update = YES END IF END IF END WITH END SUB FUNCTION reorderable_node(byval node as NodePtr) as integer IF keyval(scShift) > 0 THEN IF node THEN IF keyval(ccUp) > 1 THEN SwapNodePrev node RETURN YES ELSEIF keyval(ccDown) > 1 THEN SwapNodeNext node RETURN YES END IF END IF END IF RETURN NO END FUNCTION FUNCTION edit_purchase_enumbrowse(caption as string, byval node as NodePtr, enumstr() as string, helpkey as string) as bool DIM choice as integer DIM old as integer old = a_find(enumstr(), GetString(node), 0) choice = multichoice(caption, enumstr(), old, old, helpkey) IF choice <> old THEN SetContent(node, enumstr(choice)) RETURN YES END IF RETURN NO END FUNCTION FUNCTION edit_purchase_enumgrabber(byval node as NodePtr, enumstr() as string) as bool DIM choice as integer choice = a_find(enumstr(), GetString(node), 0) IF intgrabber(choice, 0, UBOUND(enumstr)) THEN SetContent(node, enumstr(choice)) RETURN YES END IF RETURN NO END FUNCTION FUNCTION npc_preview_text(byref npc as NPCType) as string 'This is certaily not an attempt to show everything one might need to 'know about an NPC, simply a way of showing a little more data so that 'NPCs with the same sprite can be easily distinguished in the editor at a glance IF npc.textbox > 0 THEN RETURN textbox_preview_line(npc.textbox) IF npc.script <> 0 THEN RETURN scriptname(npc.script) & " [SCRIPT]" IF npc.item > 0 THEN RETURN readitemname(npc.item - 1) & " [ITEM]" IF npc.vehicle > 0 THEN RETURN load_vehicle_name(npc.vehicle - 1) & " [VEHICLE]" END FUNCTION FUNCTION custom_setoption(opt as string, arg as string) as integer IF opt = "distrib" THEN SELECT CASE arg CASE "zip", "win", "mac", "mac32", "mac64", "tarball", "tarball64", "tarball32", "debian", "debian64", "debian32", "web", "all" auto_distrib = arg RETURN 2 END SELECT debug "-distrib doesn't know how to export """ & arg & """" RETURN 1 ELSEIF opt = "nowait" THEN option_nowait = YES RETURN 1 ELSEIF opt = "hsflags" THEN 'Have to add the - because it won't be recognised as an argument otherwise option_hsflags = "-" & arg RETURN 2 ELSEIF opt = "export-trans" THEN IF LEN(arg) THEN export_translations_to = arg RETURN 2 ELSE RETURN 1 END IF END IF RETURN 0 END FUNCTION 'Destructors can't be abstract... DESTRUCTOR RecordPreviewer() END DESTRUCTOR 'Draw the menu, to the bottom-right corner of the screen onto a background rect of the size of the game window SUB preview_menu(menu as MenuDef, mstate as MenuState, viewport_page as integer, destpage as integer = -1) draw_textured_background viewport_page draw_menu menu, mstate, viewport_page IF destpage > -1 THEN draw_viewport_page viewport_page, destpage END SUB FUNCTION game_uses_midi_or_bam() as bool 'Only checks whether a mid or bam exists. Does not go so far as to try and detect whether it ever gets played. 'In cases where multiple formats exists with the same ID, find_music_lump() detects ogg and module formats firsdt before it finds midi, and bam is detected last DIM songlump as string DIM songtype as MusicFormatEnum FOR songnum as integer = 0 TO gen(genMaxSong) songlump = find_music_lump(songnum) songtype = getmusictype(songlump) IF songtype = FORMAT_MIDI ORELSE songtype = FORMAT_BAM THEN RETURN YES END IF NEXT songnum RETURN NO END FUNCTION