diff --git a/bot/bot_skill.cfg b/bot/bot_skill.cfg index 47a5212e..44da2953 100644 --- a/bot/bot_skill.cfg +++ b/bot/bot_skill.cfg @@ -19,7 +19,7 @@ # BOT AIM VARIABLE NOTES: # # The offset and the aim speed are more base values. When a bot has to turn little the aimspeed -# and the aim offset will be lower(so he will aim more precise) and when a bot has to turn much +# and the aim offset will be lower(so he will aim more precise) and when the bot has to turn much # the aimspeed and aim offset will be higher(so he will aim fast but inaccurate). # # @@ -62,10 +62,6 @@ # Description = Sets if the bot will search for items when he has an enemy. # Unit of Measure = Bool(1 = on, 0 = off) # -# can_predict_position : -# Description = Sets if the bot will predict their enemy's position. -# Unit of Measure = Bool(1 = on, 0 = off) -# # max_hear_volume # Description = Sets 'how much' the bot can hear, how lower the value, how lower the max distance a # bot can still hear a sound. @@ -79,15 +75,72 @@ # Description = Sets the maximum distance at which the bot will always spot the enemy # Unit of Measure = Float (distance in cubes) # +# BOT SMART VARIABLES (only used by the best+ skill, keep them at 0 for the others): +# +# smart_movement : +# Description = Steers around walls instead of walking into them, and uses diagonal movement +# (two keys at once is faster) whenever that is quicker. +# +# smart_weapons : +# Description = Weighs every weapon on the damage it can land at the current distance +# (range window, accuracy, ammo and reload state) instead of using fixed bonuses. +# +# smart_grenades : +# Description = Only throws grenades when they are useful: out of our own blast radius, +# close enough to land, not into a wall and never onto a team mate. +# Also runs out of the way of live grenades. +# +# smart_targeting : +# Description = Picks enemies on threat (distance, who is aiming at us, who is nearly dead, +# who we are already looking at) instead of distance only. +# +# dodge_fire : +# Description = Reacts to taking damage by strafing and sometimes jumping instead of +# continuing to walk in a straight line. +# +# Unit of Measure = Bool(1 = on, 0 = off) +# # SKILL LEVELS: # -# There are five skill levels to edit (and choose from ingame). SKILL1 is the highest skill level -# while SKILL5 is the lowest skill level. +# There are six skill levels to edit (and choose from ingame). SKILL1 is the highest skill level +# (best+) while SKILL6 is the lowest skill level (bad). # #----------------------------------------------------------------------------------------------- [SKILL1] +min_x_aim_speed 450 +max_x_aim_speed 520 +min_y_aim_speed 500 +max_y_aim_speed 560 + +min_x_aim_offset 0 +max_x_aim_offset 2 +min_y_aim_offset 0 +max_y_aim_offset 2 + +min_attack_delay 0.02 +max_attack_delay 0.08 + +min_enemy_search_delay 0.04 +max_enemy_search_delay 0.07 + +max_always_detect_distance 20 + +can_predict_position 1 +can_circle_strafe 1 +can_search_items_in_combat 1 + +smart_movement 1 +smart_weapons 1 +smart_grenades 1 +smart_targeting 1 +dodge_fire 1 + +max_hear_volume 100 +field_of_view 240 + +[SKILL2] min_x_aim_speed 330 max_x_aim_speed 355 min_y_aim_speed 350 @@ -113,7 +166,7 @@ can_search_items_in_combat 1 max_hear_volume 75 field_of_view 200 -[SKILL2] +[SKILL3] min_general_delay 0.035 max_general_delay 0.045 @@ -142,7 +195,7 @@ can_search_items_in_combat 1 max_hear_volume 60 field_of_view 160 -[SKILL3] +[SKILL4] min_x_aim_speed 190 max_x_aim_speed 225 min_y_aim_speed 210 @@ -168,7 +221,7 @@ can_search_items_in_combat 0 max_hear_volume 40 field_of_view 140 -[SKILL4] +[SKILL5] min_x_aim_speed 155 max_x_aim_speed 170 min_y_aim_speed 175 @@ -194,7 +247,7 @@ can_search_items_in_combat 0 max_hear_volume 30 field_of_view 120 -[SKILL5] +[SKILL6] min_x_aim_speed 60 max_x_aim_speed 90 min_y_aim_speed 80 diff --git a/config/docs.cfg b/config/docs.cfg index e3e90f63..84012629 100644 --- a/config/docs.cfg +++ b/config/docs.cfg @@ -4223,14 +4223,14 @@ docref [waterlevel]; docsection [Bot mode] docident [addbot] [Adds a bot for a given team with a given skill calling him a given name.]; docargument [T] [team] [RVSF or CLA] [0]; -docargument [S] [skill] [best, good, medium, worse, bad] [0]; +docargument [S] [skill] [best+, best, good, medium, worse, bad] [0]; docargument [N] [name] [name for the bot] [0]; docremark [This command only works for single player modes.]; docexample [addbot RVSF medium Robbie] [Will add a bot named Robbie with a medium skill level to the RVSF team.]; docident [addnbot] [Adds a given count of bots for the given team with the given skill and select random names for them.]; docargument [C] [count] [how many bots to add] [0]; docargument [T] [team] [RVSF or CLA] [0]; -docargument [S] [skill] [best, good, medium, worse, bad] [0]; +docargument [S] [skill] [best+, best, good, medium, worse, bad] [0]; docremark [This command only works for single player modes.]; docremark [The name of the bots will be selected randomly.]; docexample [addnbot 2 CLA bad] [Will add 2 bots with a bad skill level to the CLA team.]; @@ -4243,10 +4243,10 @@ docargument [A] [connect automatically] [0 or 1] [0]; docident [autowp] [Automatically places waypoints.]; docident [botskill] [Changes the skill level for the given bot.]; docargument [N] [botname] [the name of the bot] [0]; -docargument [S] [botskill] [best, good, medium, worse, bad] [0]; +docargument [S] [botskill] [best+, best, good, medium, worse, bad] [0]; docexample [botskill Robbie best] [Changes the previous bot skill level of the bot named Robbie to a 'best' skill level.]; docident [botskillall] [Changes the skill level for all bots.]; -docargument [S] [botskill] [best, good, medium, worse, bad] [0]; +docargument [S] [botskill] [best+, best, good, medium, worse, bad] [0]; docexample [botskillall worse] [Changes the previous bot skill level for all bots to a 'worse' skill level.]; docident [botsshoot] [Enables or disables the ability of the bots to fire their weapons.]; docargument [T] [shooting bots?] [0 or 1] [0]; diff --git a/config/menus.cfg b/config/menus.cfg index cad673dc..91f148ef 100644 --- a/config/menus.cfg +++ b/config/menus.cfg @@ -25,11 +25,14 @@ const __maps_most [ac_africa ac_alcove ac_aqueous ac_arabian ac_arctic ac_arctic const __maps_flags [ac_africa ac_alcove ac_aqueous ac_arabian ac_arctic2 ac_arid ac_avenue ac_coal ac_depot ac_desert3 ac_dusk ac_edifice ac_elevation ac_gothic ac_industrial ac_ingress ac_kazemat ac_keller ac_lainio ac_lotus ac_mines ac_nocturne ac_origin ac_outpost ac_power ac_rampart ac_shine ac_stellar ac_sunset ac_swamp ac_terros ac_urban ac_venison ac_werk] // this list is like maps_flags (but with ac_douze) that support TKTF const __maps_tktf [ac_africa ac_alcove ac_aqueous ac_arabian ac_arctic2 ac_arid ac_avenue ac_coal ac_depot ac_desert3 ac_douze ac_dusk ac_edifice ac_elevation ac_gothic ac_industrial ac_ingress ac_kazemat ac_keller ac_lainio ac_lotus ac_mines ac_nocturne ac_origin ac_outpost ac_power ac_rampart ac_shine ac_stellar ac_sunset ac_swamp ac_terros ac_urban ac_venison ac_werk] +// fork maps: silly maps that are not balanced AssaultCube maps (converted maps, experiments, ...) +// just add the map name here to get it into the "Maps+" submenu +const __maps_plus [dust2css] // Start maps. const __genmapitems [ looplisti $arg1 m [ - menuitemmapload $m [map @m; if (= (curmastermode) 2) [ showmenu [Match Settings] ] ] + menuitemmapload $m [map @m; if (= (curmastermode) 2) [ __matchsettings_bot = 0; showmenu [Match Settings] ] ] [nextmap_@m] = (at $arg1 (mod (+ $i 1) @(listlen $arg1))) ] ] @@ -37,30 +40,40 @@ const __genmapitems [ newmenu all_custom_maps menutitle "custom maps" menurenderoffset -20 0 -menudirlist "packages/maps" cgz [map $arg1; if (= (curmastermode) 2) [ showmenu [Match Settings] ] ] +menudirlist "packages/maps" cgz [map $arg1; if (= (curmastermode) 2) [ __matchsettings_bot = 0; showmenu [Match Settings] ] ] newmenu maps __genmapitems $__maps_all menuitem [] -1 menuitem "Show all custom maps\i\3" [showmenu all_custom_maps] +menuitem "Maps+\i\3" [showmenu maps_plus] newmenu maps_most menutitle "maps" __genmapitems $__maps_most menuitem [] -1 menuitem "Show all custom maps\i\3" [showmenu all_custom_maps] +menuitem "Maps+\i\3" [showmenu maps_plus] newmenu maps_flags menutitle "maps with flags" __genmapitems $__maps_flags menuitem [] -1 menuitem "Show all custom maps\i\3" [showmenu all_custom_maps] +menuitem "Maps+\i\3" [showmenu maps_plus] newmenu maps_tktf menutitle "maps with flags" __genmapitems $__maps_tktf menuitem [] -1 menuitem "Show all custom maps\i\3" [showmenu all_custom_maps] +menuitem "Maps+\i\3" [showmenu maps_plus] + +newmenu maps_plus +menutitle "maps+" +__genmapitems $__maps_plus +menuitem [] -1 +menuitem "Show all custom maps\i\3" [showmenu all_custom_maps] //// Main > Play demo //// diff --git a/config/menus_bot.cfg b/config/menus_bot.cfg index 4d374b12..ebfc6cf0 100644 --- a/config/menus_bot.cfg +++ b/config/menus_bot.cfg @@ -4,13 +4,22 @@ newmenu botmaps const genbotmaps [ looplist $arg1 listmapname [ - menuitemmapload $listmapname (concat "tmp_botmap =" $listmapname "; showmenu [Match Settings]") + menuitemmapload $listmapname (concat "tmp_botmap =" $listmapname "; __matchsettings_bot = 1; showmenu [Match Settings]") ] ] // problems with quite a number of maps when playing against bots (waypoints simply flooded) have lead to generating lists to avoid these //known issues on: (BTDM) ac_alcove ac_aqueous ac_avenue ac_coal ac_depot ac_kazemat ac_keller ac_outpost ac_power ac_shine ac_terros ac_urban const __bot_maps_all [ac_africa ac_arabian ac_arctic ac_arctic2 ac_arid ac_complex ac_desert ac_desert2 ac_desert3 ac_douze ac_dusk ac_edifice ac_elevation ac_gothic ac_industrial ac_ingress ac_lainio ac_lotus ac_mines ac_nocturne ac_origin ac_rampart ac_rattrap ac_scaffold ac_snow ac_stellar ac_sunset ac_swamp ac_toxic ac_venison ac_wasteland ac_werk] genbotmaps $__bot_maps_all +menuitem [] -1 +menuitem "Maps+\i\3" [showmenu botmaps_plus] + +// the fork maps of menus.cfg ($__maps_plus), with bots +newmenu botmaps_plus +menutitle "maps+" +genbotmaps $__maps_plus +menuitem [] -1 +menuitem "Back to bot maps\i\3" [showmenu botmaps] tempalias tmp_botskill good tempalias tmp_numbots 1 @@ -107,6 +116,7 @@ const showmenu_enemies [ if ($tmp_isbotteammode) [ showmenu [team enemies] ] [ s newmenu botskill +menuitem "Best+ (brutal)" [ tmp_botskill = best+; showmenu_enemies ] menuitem Best [ tmp_botskill = best; showmenu_enemies ] menuitem "Good (default)" [ tmp_botskill = good; showmenu_enemies ] menuitem Medium [ tmp_botskill = medium; showmenu_enemies ] diff --git a/config/menus_multiplayer.cfg b/config/menus_multiplayer.cfg index 9f51d56a..a2ef99f1 100644 --- a/config/menus_multiplayer.cfg +++ b/config/menus_multiplayer.cfg @@ -85,7 +85,7 @@ menuinit [ menuitem "Kick/ban player\i\3" [ showmenu kick/ban ] menuitem "Adjust teams\i\3" [ showmenu [Team adjustment] ] menuitem "Change mastermode\i\3" [ showmenu Mastermode ] - menuitem "Match Settings\i\3" [ showmenu [Match Settings] ] + menuitem "Match Settings\i\3" [ __matchsettings_bot = 0; showmenu [Match Settings] ] menuitem "Change the server description\i\3" [ saycommand "/serverdesc " ] menuitem "Find a players IP address\i\3" [ saycommand "/whois " ] menuitem "Give admin\i\3" [ showmenu [give admin] ] @@ -162,38 +162,51 @@ newmenu Mastermode menuadminonly 2 menuitem [Unlock the server (open)] [ mastermode 0 ] menuitem [Lock the server (private)] [ mastermode 1 ] -menuitem [Lock the server (match mode)] [ mastermode 2; showmenu [Match Settings] ] +menuitem [Lock the server (match mode)] [ mastermode 2; __matchsettings_bot = 0; showmenu [Match Settings] ] //// In-Game admin > Match Settings //// +// 1 while setting up a singleplayer bot match (set by menus_bot.cfg); the "Start Bot +// Match" button and the way back to the bot map list are only offered in that case. +__matchsettings_bot = 0 + +const __matchsettings_items [ + menuitemslider "Recoil Multiplier: " 50 500 "$matchrecoilmul" 10 [ matchset matchrecoilmul $arg1 ] + menuitemslider "Spread Multiplier: " 50 500 "$matchspreadmul" 10 [ matchset matchspreadmul $arg1 ] + menuitemslider "Speed Multiplier: " 50 500 "$matchspeedmul" 10 [ matchset matchspeedmul $arg1 ] + menuitemslider "Gravity Multiplier: " 50 500 "$matchgravity" 10 [ matchset matchgravity $arg1 ] + menuitemslider "Friction Multiplier: " 10 500 "$matchfriction" 10 [ matchset matchfriction $arg1 ] + menuitem "" -1 + menuitemcheckbox "Enable Assists: " "$matchassist" [ matchset matchassist $arg1 ] + menuitemcheckbox "Reload cancel on switch: " "$matchreloadcancel" [ matchset matchreloadcancel $arg1 ] + menuitemcheckbox "Knife+jump speed boost: " "$matchknifeboost" [ matchset matchknifeboost $arg1 ] + menuitemcheckbox "Medkit pickup item: " "$matchmedkit" [ matchset matchmedkit $arg1 ] + menuitemcheckbox "Slow backwards walk (70%): " "$matchbackwards" [ matchset matchbackwards $arg1 ] + menuitemcheckbox "Ceiling bump speed boost: " "$matchheadboost" [ matchset matchheadboost $arg1 ] + menuitemcheckbox "Source-style physics (bhop/air strafe): " "$matchsourcephysics" [ matchset matchsourcephysics $arg1 ] + menuitemcheckbox "Explosive Deaths: " "$matchdeathexplosion" [ matchset matchdeathexplosion $arg1 ] + menuitemslider " Blast Radius %: " 10 300 "$matchdeathradius" 10 [ matchset matchdeathradius $arg1 ] + menuitemslider " Blast Power %: " 10 500 "$matchdeathpower" 10 [ matchset matchdeathpower $arg1 ] + menuitem "" -1 + menuitem "Friendly Fire\i\3" [ showmenu [Match Friendly Fire] ] + menuitem "" -1 + menuitem "Spawn Weapons\i\3" [ showmenu [Match Spawn Weapons] ] + if $__matchsettings_bot [ + menuitem "" -1 + menuitem "\f2Start Bot Match" [ startbotmap ] + ] + menuitem "" -1 + menuitem "Reset to defaults" [ matchsetreset; showmenu [Match Settings] ] + menuitem "Back" [ showmenu @(at [Mastermode botmaps] $__matchsettings_bot) ] +] + newmenu [Match Settings] menuadminonly 2 -menuinit [ ] -menuitemslider "Recoil Multiplier: " 50 500 "$matchrecoilmul" 10 [ matchset matchrecoilmul $arg1 ] -menuitemslider "Spread Multiplier: " 50 500 "$matchspreadmul" 10 [ matchset matchspreadmul $arg1 ] -menuitemslider "Speed Multiplier: " 50 500 "$matchspeedmul" 10 [ matchset matchspeedmul $arg1 ] -menuitemslider "Gravity Multiplier: " 50 500 "$matchgravity" 10 [ matchset matchgravity $arg1 ] -menuitemslider "Friction Multiplier: " 10 500 "$matchfriction" 10 [ matchset matchfriction $arg1 ] -menuitem "" -1 -menuitemcheckbox "Enable Assists: " "$matchassist" [ matchset matchassist $arg1 ] -menuitemcheckbox "Reload cancel on switch: " "$matchreloadcancel" [ matchset matchreloadcancel $arg1 ] -menuitemcheckbox "Knife+jump speed boost: " "$matchknifeboost" [ matchset matchknifeboost $arg1 ] -menuitemcheckbox "Medkit pickup item: " "$matchmedkit" [ matchset matchmedkit $arg1 ] -menuitemcheckbox "Slow backwards walk (70%): " "$matchbackwards" [ matchset matchbackwards $arg1 ] -menuitemcheckbox "Ceiling bump speed boost: " "$matchheadboost" [ matchset matchheadboost $arg1 ] -menuitemcheckbox "Source-style physics (bhop/air strafe): " "$matchsourcephysics" [ matchset matchsourcephysics $arg1 ] -menuitemcheckbox "Explosive Deaths: " "$matchdeathexplosion" [ matchset matchdeathexplosion $arg1 ] -menuitemslider " Blast Radius %: " 10 300 "$matchdeathradius" 10 [ matchset matchdeathradius $arg1 ] -menuitemslider " Blast Power %: " 10 500 "$matchdeathpower" 10 [ matchset matchdeathpower $arg1 ] -menuitem "" -1 -menuitem "Friendly Fire\i\3" [ showmenu [Match Friendly Fire] ] -menuitem "" -1 -menuitem "Spawn Weapons\i\3" [ showmenu [Match Spawn Weapons] ] -menuitem "" -1 -menuitem "\f2Start Bot Match" [ startbotmap ] -menuitem "" -1 -menuitem "Reset to defaults" [ matchsetreset; showmenu [Match Settings] ] -menuitem "Back" [ showmenu Mastermode ] +menuinit [ + resetcurmenu + menuadminonly 2 + __matchsettings_items +] //// In-Game admin > Match Settings > Friendly Fire //// diff --git a/config/servercmdline.txt b/config/servercmdline.txt index 248c0f0f..0589257c 100644 --- a/config/servercmdline.txt +++ b/config/servercmdline.txt @@ -15,7 +15,7 @@ // example MOTD: -o \f3server using example cmdline file (config/servercmdline.txt)\n\f2gassy server 🥶 - -f 27015 +// -f 27015 // commonly used commandline switches: diff --git a/docs/cube_bot-readme.txt b/docs/cube_bot-readme.txt index 384553a1..c84b5e4d 100644 --- a/docs/cube_bot-readme.txt +++ b/docs/cube_bot-readme.txt @@ -134,7 +134,7 @@ Adds a bot in the current game. Command arguments: : Specifies the team for the bot, when you leave it blank or use "random" as team the bot will choose a team from the file 'bot/bot_teams.txt'. -: The skill of the bot; can be best, good, medium, worse, bad, default or random. +: The skill of the bot; can be best+, best, good, medium, worse, bad, default or random. When blank or "default" is used as skill, the bot will use skill good. When "random" is used, the bot will use a random skill. : The name for this bot. When blank the bot will pick a name from the @@ -154,7 +154,7 @@ Adds multiple bots to the game. Command arguments: : Specifies the team for the bots, when you leave it blank or use "random" as team the bot will choose a team from the file 'bot/bot_teams.txt'. -: The skill of the bot; can be best, good, medium, worse, bad, default or random. +: The skill of the bot; can be best+, best, good, medium, worse, bad, default or random. When blank or "default" is used as skill, the bot will use skill good. When "random" is used, the bot will use a random skill. @@ -172,7 +172,7 @@ Will kick all bots from the server botskill Will change the skill of the bot with the name to . - can be best, good, medium, worse or bad. + can be best+, best, good, medium, worse or bad. Example: To change the skill of the bot Joe to best: @@ -181,7 +181,7 @@ botskill Joe best botskillall Will change the bot skill and the default skill to . - can be best, good, medium, worse or bad. + can be best+, best, good, medium, worse or bad. 5.3 List of all client side commands(Works if you're a host or joined a server): diff --git a/docs/reference.xml b/docs/reference.xml index a6348219..182d902e 100644 --- a/docs/reference.xml +++ b/docs/reference.xml @@ -11677,7 +11677,7 @@ name="AssaultCube Documentation" version="Current" xsi:schemaLocation="xml/cuber Adds a bot for a given team with a given skill calling him a given name. - + @@ -11696,7 +11696,7 @@ name="AssaultCube Documentation" version="Current" xsi:schemaLocation="xml/cuber - + This command only works for single player modes. @@ -11714,7 +11714,7 @@ name="AssaultCube Documentation" version="Current" xsi:schemaLocation="xml/cuber Changes the skill level for the given bot. - + @@ -11727,7 +11727,7 @@ name="AssaultCube Documentation" version="Current" xsi:schemaLocation="xml/cuber Changes the skill level for all bots. - + diff --git a/source/dev_tools/bsp2cgz.py b/source/dev_tools/bsp2cgz.py new file mode 100644 index 00000000..e9c15e36 --- /dev/null +++ b/source/dev_tools/bsp2cgz.py @@ -0,0 +1,1920 @@ +#!/usr/bin/env python3 +""" +bsp2cgz.py - convert a Source engine .bsp map into an AssaultCube .cgz map. + + python3 source/dev_tools/bsp2cgz.py surf_map.bsp + +Only the map itself is converted: + + * every brush that blocks movement (solid, playerclip, monsterclip, grate, + window, moveable clip) becomes the cube world. func_detail geometry is part + of the world model, brushes of brush entities (func_brush, doors, ...) are + baked in their compiled position. + * info_player_* entities become PLAYERSTART entities (a map without spawns can + not be played), light entities become LIGHT entities (--entities spawns + drops them) and the sun (light_environment) becomes the map's ambient light, + because an AssaultCube map without lights is pitch black. + * Source maps are usually a sealed playable area plus a 3D skybox (a scaled + down copy of the map placed far away). Only the area that is connected to + the player spawns is converted - the skybox would add junk and, worse, make + the whole map coarse. -w/--whole-map converts everything. + * everything else (entity i/o, triggers, func_ boxes, displacements, overlays, + static props, models, cubemaps, scripts) is dropped. + +Caveats - these are properties of the target format, not bugs: + + * AssaultCube worlds are columns: one floor and one ceiling height per (x, y) + cell with 1/4 cube slope precision. Overhangs, bridges, stacked floors and + interiors can not be represented, so one open space per column is kept + (--space, default: the topmost one, which is what surf/aim maps want). + * heights are limited to +-127 cubes, and a smoothed (heightfield) surface can + dip at most 63.5 cubes below its highest point. When a map is too tall for + that, ramps are written as stair steps instead - still walkable, but the + surface is rendered as small steps (pass a coarser -s for smooth ramps). + * the AC network protocol refuses map files of 64k and more, so big + conversions are for local/bot play unless the scale is made coarser. +""" + +import argparse +import gzip +import math +import os +import re +import struct +import sys +import time +from array import array +from collections import defaultdict + +# --------------------------------------------------------------- BSP lumps + +L_ENTITIES = 0 +L_PLANES = 1 +L_TEXDATA = 2 +L_VERTEXES = 3 +L_NODES = 5 +L_TEXINFO = 6 +L_FACES = 7 +L_LEAFS = 10 +L_EDGES = 12 +L_SURFEDGES = 13 +L_MODELS = 14 +L_BRUSHES = 18 +L_BRUSHSIDES = 19 +L_TEXDATA_STRING_DATA = 43 +L_TEXDATA_STRING_TABLE = 44 +L_BRUSH_PLANES = 22 # version 21 moved brush planes to their own lump + +CONTENTS_SOLID = 0x1 +CONTENTS_WINDOW = 0x2 +CONTENTS_GRATE = 0x8 +CONTENTS_ORIGIN = 0x8000000 # no collision: only there to place an entity +CONTENTS_SLIME = 0x10 +CONTENTS_WATER = 0x20 +CONTENTS_MOVEABLECLIP = 0x8000 +CONTENTS_PLAYERCLIP = 0x10000 +CONTENTS_MONSTERCLIP = 0x20000 + +BLOCKING = (CONTENTS_SOLID | CONTENTS_WINDOW | CONTENTS_GRATE + | CONTENTS_MOVEABLECLIP | CONTENTS_PLAYERCLIP | CONTENTS_MONSTERCLIP) +LIQUID = CONTENTS_SLIME | CONTENTS_WATER + +I32 = struct.Struct(" len(self.data): + raise BspError("lump %d is out of bounds" % index) + return self.data[offset:offset + length] + + def lump_entries(self, index, fmt): + raw = self.lump(index) + if len(raw) % fmt.size: + raise BspError("lump %d has a size (%d) that is not a multiple of %d, " + "this .bsp layout is not supported" + % (index, len(raw), fmt.size)) + return [fmt.unpack_from(raw, i) for i in range(0, len(raw), fmt.size)] + + # -- geometry -------------------------------------------------------- + + def load_geometry(self): + self.planes = self.lump_entries(L_PLANES, PLANE) + self.vertices = [v[:3] for v in self.lump_entries(L_VERTEXES, VERTEX)] + self.edges = self.lump_entries(L_EDGES, EDGE) + self.surfedges = [e[0] for e in self.lump_entries(L_SURFEDGES, SURFEDGE)] + self.models = self.lump_entries(L_MODELS, MODEL) + planes = self.planes + # version 21 moved the brush planes to their own lump, but not every map + # written by a v21 tool actually uses it (the lump may hold something + # else), so it is only used when the brush sides really index into it + brushsides = self.lump_entries(L_BRUSHSIDES, BRUSHSIDE) + maxplane = max((s[0] for s in brushsides), default=-1) + self.brushplanes = planes + if self.version >= 21: + try: + bp = self.lump_entries(L_BRUSH_PLANES, PLANE) + except BspError: + bp = [] + if bp and maxplane < len(bp): + self.brushplanes = bp + self.faces = [] + raw = self.lump(L_FACES) + if len(raw) % FACE.size: + raise BspError("the face lump has an unexpected size (%d)" % len(raw)) + for i in range(0, len(raw), FACE.size): + (planenum, side, _onn, firstedge, numedges, texinfo, _disp, _fog, + _s0, _s1, _s2, _s3, _lightofs, _area, _a, _b, _c, _d, _orig, + _np, _fp, _smooth) = FACE.unpack_from(raw, i) + if planenum >= len(planes): + raise BspError("face %d references plane %d" % (i // FACE.size, planenum)) + nx, ny, nz, dist, _ptype = planes[planenum] + if side: + nx, ny, nz, dist = -nx, -ny, -nz, -dist + self.faces.append(((nx, ny, nz, dist), + self.face_polygon(firstedge, numedges), texinfo)) + self.brushes = [] + for firstside, numsides, contents in self.lump_entries(L_BRUSHES, BRUSH): + if firstside < 0 or numsides < 0 or firstside + numsides > len(brushsides): + raise BspError("brush side range is out of bounds") + hs = [] + for s in range(firstside, firstside + numsides): + planenum = brushsides[s][0] + if planenum >= len(self.brushplanes): + raise BspError("brush side references plane %d" % planenum) + hs.append(self.brushplanes[planenum][:4]) + if len(hs) >= 4: + self.brushes.append((contents, hs)) + + # -- what the engine thinks is solid --------------------------------- + + def load_tree(self): + """the BSP tree of the world model: the same thing the engine walks to + decide whether a point is inside a wall.""" + self.nodes = self.lump(L_NODES) + self.leafs = self.lump(L_LEAFS) + self.headnode = self.models[0][9] if self.models else 0 + self.tree = bool(self.nodes) and bool(self.leafs) and self.headnode >= 0 + return self.tree + + def column_segments(self, x, y, zlo, zhi): + """the vertical sequence of (lo, hi, contents) the map has at (x, y). + + Walking the tree instead of the brush boxes gives exactly what the game + uses: trigger volumes, buyzones, origin helpers (which are brushes too, + but are not solid) and clip brushes all come out right. + """ + nodes, leafs, planes = self.nodes, self.leafs, self.planes + out = [] + stack = [(self.headnode, zlo, zhi)] + limit = len(nodes) - 24 + nplanes = len(planes) + while stack: + node, lo, hi = stack.pop() + if node < 0: + o = (-node - 1) * 32 + contents = struct.unpack_from(" limit or hi - lo < 1e-6: + continue + planenum = struct.unpack_from("= nplanes: + continue + nx, ny, nz, d = planes[planenum][:4] + child0, child1 = struct.unpack_from(" 1e-9 or nz < -1e-9: + zs = (d - nx * x - ny * y) / nz + front, back = (child0, child1) if nz > 0 else (child1, child0) + if zs > lo + 1e-9: + stack.append((back, lo, min(hi, zs))) + if zs < hi - 1e-9: + stack.append((front, max(lo, zs), hi)) + else: + front = (nx * x + ny * y - d) > 0 + stack.append((child0 if front else child1, lo, hi)) + out.sort() + return out + + def leaf_contents(self, x, y, z): + """what the map has at one point: a single walk down to a leaf""" + nodes, leafs, planes = self.nodes, self.leafs, self.planes + node = self.headnode + limit = len(nodes) - 24 + nplanes = len(planes) + while node >= 0: + o = node * 32 + if o > limit: + return 0 + planenum = struct.unpack_from("= nplanes: + return 0 + nx, ny, nz, d = planes[planenum][:4] + child0, child1 = struct.unpack_from(" 0 else child1 + o = (-node - 1) * 32 + if o + 4 > len(leafs): + return 0 + return struct.unpack_from(" len(self.surfedges): + return [] + poly = [] + for i in range(firstedge, firstedge + numedges): + se = self.surfedges[i] + if se >= 0: + v = self.edges[se][0] if se < len(self.edges) else -1 + else: + v = self.edges[-se][1] if -se < len(self.edges) else -1 + if 0 <= v < len(self.vertices): + poly.append(self.vertices[v]) + return poly + + # -- names and entities ---------------------------------------------- + + def material_names(self): + data = self.lump(L_TEXDATA_STRING_DATA) + names = [] + for (off,) in self.lump_entries(L_TEXDATA_STRING_TABLE, I32): + if off < 0 or off >= len(data): + names.append("") + continue + end = data.find(b"\0", off) + if end < 0: + end = len(data) + names.append(data[off:end].decode("latin-1")) + out = [] + for td in self.lump_entries(L_TEXDATA, TEXDATA): + idx = td[3] + out.append(names[idx].lower() if 0 <= idx < len(names) else "") + return out + + def face_materials(self): + names = self.material_names() + texinfos = self.lump_entries(L_TEXINFO, TEXINFO) + mats = [] + for _n, _poly, texinfo in self.faces: + if 0 <= texinfo < len(texinfos): + td = texinfos[texinfo][17] + mats.append(names[td] if 0 <= td < len(names) else "") + else: + mats.append("") + return mats + + def entities(self): + raw = self.lump(L_ENTITIES) + if not raw: + return [] + text = raw.decode("utf-8", "replace") + out = [] + i = 0 + while True: + start = text.find("{", i) + if start < 0: + break + end = text.find("}", start) + if end < 0: + break + i = end + 1 + ent = {} + for m in re.finditer(r'"([^"]*)"\s+"([^"]*)"', text[start:end]): + ent[m.group(1).lower()] = m.group(2) + if ent: + out.append(ent) + return out + + +# --------------------------------------------------------------- AC textures + +DEFAULT_SKY, DEFAULT_LIQUID, DEFAULT_WALL, DEFAULT_FLOOR, DEFAULT_CEIL = 0, 1, 2, 3, 4 +ROLE_ROOTS = { + "floor": ("floor", "ground", "grass", "sand", "dirt", "gravel", "cobble", + "tile", "pavement", "road", "concrete", "metal", "wood", "grate", + "rock", "snow", "ice", "carpet"), + "ceil": ("ceil", "roof", "plaster", "concrete", "panel", "sky", "metal"), + "wall": ("wall", "brick", "stone", "concrete", "metal", "panel", "plaster", + "wood", "rock", "cliff", "tile", "grate"), +} +SKY_WORDS = ("skybox", "sky") +INVISIBLE_WORDS = ("nodraw", "clip", "trigger", "hint", "skip", "invisible", + "origin", "areaportal", "occluder", "blocklight", "fog", + "ladder", "func_") +WATER_WORDS = ("water", "slime", "lava", "liquid") + + +def tokenize(name): + """'concrete/concretefloor011a' -> {'concrete', 'floor'}""" + base = (name or "").replace("\\", "/").split("/")[-1].lower() + base = re.sub(r"\.(jpg|jpeg|png|tga|vmt|vtf)$", "", base) + out = set() + for w in re.split(r"[^a-z]+", base): + if len(w) < 3: # 'a', 'b2' and friends are noise, they match everything + continue + if len(w) > 3 and w.endswith("s") and not w.endswith("ss"): + w = w[:-1] + out.add(w) + return out + + +class AcTextures(object): + """The texture slot list of config/default_map_settings.cfg.""" + + def __init__(self, cfg_path): + self.names = [] + if cfg_path and os.path.exists(cfg_path): + with open(cfg_path, "r", errors="replace") as f: + for line in f: + line = line.strip() + if line.startswith("//"): + continue + m = re.match(r'texture\s+\d+\s+"([^"]+)"', line) + if m: + self.names.append(m.group(1)) + self.tokens = [tokenize(n) for n in self.names] + self.cache = {} + + def _default(self, role): + return {"floor": DEFAULT_FLOOR, "ceil": DEFAULT_CEIL}.get(role, DEFAULT_WALL) + + def slot(self, material, role): + key = (material, role) + hit = self.cache.get(key) + if hit is None: + hit = self._slot(material, role) + self.cache[key] = hit + return hit + + def _slot(self, material, role): + m = (material or "").lower() + if any(w in m for w in SKY_WORDS): + return DEFAULT_SKY + if any(w in m for w in WATER_WORDS): + return DEFAULT_LIQUID + if not m or any(w in m for w in INVISIBLE_WORDS) or m.startswith("tools/") \ + or m.startswith("editor/"): + return self._default(role) + # the material name is matched against the AC texture names as strings: + # the interesting word is usually part of a longer token ('concretefloor') + roots = ROLE_ROOTS.get(role, ()) + best, bestscore = None, 0 + for i, stokens in enumerate(self.tokens): + if i == DEFAULT_SKY: + continue + score = 0 + for w in stokens: + if len(w) >= 4 and w in m: + score += 3 + for w in roots: + if w in stokens and w in m: + score += 2 + if score > bestscore: + best, bestscore = i, score + if best is None or bestscore < 3: + return self._default(role) + return best + + +def find_actextures(explicit): + if explicit: + return explicit if os.path.exists(explicit) else None + here = os.path.dirname(os.path.abspath(__file__)) + for up in (here, os.path.dirname(here), os.path.dirname(os.path.dirname(here))): + cand = os.path.join(up, "config", "default_map_settings.cfg") + if os.path.exists(cand): + return cand + return None + + +# --------------------------------------------------------------- brushes + +BIG = 1.0e5 +EPS = 1e-4 + + +def brush_footprint(halfspaces, z_abs): + """XY footprint polygon of a brush (from its vertical planes only). + + It is a superset of the real footprint, which is all that binning needs.""" + poly = [(-BIG, -BIG), (BIG, -BIG), (BIG, BIG), (-BIG, BIG)] + for nx, ny, nz, dist in halfspaces: + if abs(nz) > 0.001: + continue + if math.hypot(nx, ny) < 1e-9: + continue + c = (dist + abs(nz) * z_abs + 0.01) / math.hypot(nx, ny) + a, b = nx / math.hypot(nx, ny), ny / math.hypot(nx, ny) + out = [] + n = len(poly) + for i in range(n): + x1, y1 = poly[i] + x2, y2 = poly[(i + 1) % n] + d1 = a * x1 + b * y1 - c + d2 = a * x2 + b * y2 - c + if d1 <= 0: + out.append((x1, y1)) + if (d1 <= 0) != (d2 <= 0): + t = d1 / (d1 - d2) + out.append((x1 + (x2 - x1) * t, y1 + (y2 - y1) * t)) + poly = out + if len(poly) < 3: + return [] + return poly + + +def solid_span(planes, px, py): + """the (lo, hi) the brush occupies at column (px, py), or None""" + lo, hi = -BIG, BIG + for i in range(0, len(planes), 4): + nx, ny, nz, d = planes[i:i + 4] + b = nx * px + ny * py - d + if nz > EPS: + t = -b / nz + if t < hi: + hi = t + elif nz < -EPS: + t = -b / nz + if t > lo: + lo = t + elif b > EPS: + return None + if lo >= hi: + return None + return (lo, hi) if lo < hi else None + + +def column_intervals(cand, brushes, px, py): + """merged solid intervals along the vertical line through (px, py)""" + ivs = [] + for i in cand: + iv = solid_span(brushes[i], px, py) + if iv is not None: + ivs.append(iv) + if not ivs: + return () + ivs.sort() + out = [] + lo, hi = ivs[0] + for a, b in ivs[1:]: + if a <= hi + EPS: + if b > hi: + hi = b + else: + out.append((lo, hi)) + lo, hi = a, b + out.append((lo, hi)) + return tuple(out) + + +BLOCKING_LEAF = (CONTENTS_SOLID | CONTENTS_WINDOW | CONTENTS_GRATE | + CONTENTS_MOVEABLECLIP | CONTENTS_PLAYERCLIP) + + +def solid_segments(segments): + """merge the (lo, hi, contents) runs of a column into solid intervals""" + out = [] + lo = hi = None + for a, b, contents in segments: + if contents & BLOCKING_LEAF: + if lo is None: + lo, hi = a, b + elif a <= hi + EPS: + hi = max(hi, b) + else: + out.append((lo, hi)) + lo, hi = a, b + elif lo is not None: + out.append((lo, hi)) + lo = hi = None + if lo is not None: + out.append((lo, hi)) + return out + + +def air_spans(ivs, zlo, zhi): + out = [] + z = zlo + for lo, hi in ivs: + if lo > z: + out.append((z, lo)) + if hi > z: + z = hi + if z >= zhi: + break + if z < zhi: + out.append((z, zhi)) + return out + + +# --------------------------------------------------------- the conversion + +SOLID, CORNER, FHF, CHF, SPACE = 0, 1, 2, 3, 4 +CUBE_TYPE_NAMES = ("solid", "corner", "floor-hf", "ceil-hf", "space") +MINBORD = 2 +REGION_CELL = 256 # source units per cell when picking the main area +ZOFF = 4 # lowest geometry sits 4 cubes above height 0 +SKY_HEADROOM = 12 # an open column keeps this much room under its sky +SMOOTH_LIMIT = 63.5 # vdelta: 8 bit, 1/4 cube steps +HEIGHT_LIMIT = 126 +MIN_SCALE = 10.6666666 # 8 source units; AC players move 30 cells/s +MAX_SCALE = 256.0 +MAX_SFACTOR = 11 +MAXMAPSENDSIZE = 65536 +MAXCOL = 6 # open spaces considered per column +CONNECT_CLEARANCE = 1.1 # cubes two spaces must overlap to count as connected +A_AMBIENT_BASE = 32 # map base light: the dark part of the sky light +A_AMBIENT_SUN = 56 # ... plus this much, scaled by the sky brightness + +E_LIGHT, E_PLAYERSTART = 1, 2 +SPAWN_CLASSES = { + "info_player_start": 100, "info_player_deathmatch": 100, + "info_player_teamspawn": 100, "info_player_combine": 100, + "info_player_rebel": 100, "info_player_allies": 100, "info_player_axis": 100, + "info_player_terrorist": 1, "info_player_counterterrorist": 0, +} + + +def parse_vec(text): + try: + parts = (text or "").replace(",", " ").split() + return float(parts[0]), float(parts[1]), float(parts[2]) + except (ValueError, IndexError): + return None + + +class Converter(object): + def __init__(self, bsp, args, log): + self.bsp = bsp + self.args = args + self.log = log + self.warnings = [] + self.stats = defaultdict(int) + self.textures = None + + def warn(self, msg): + self.warnings.append(msg) + self.log("warning: " + msg) + + # -- setup ----------------------------------------------------------- + + @staticmethod + def box(poly): + xs = [v[0] for v in poly] + ys = [v[1] for v in poly] + zs = [v[2] for v in poly] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + @staticmethod + def union_boxes(boxes): + return (min(b[0] for b in boxes), min(b[1] for b in boxes), + min(b[2] for b in boxes), max(b[3] for b in boxes), + max(b[4] for b in boxes), max(b[5] for b in boxes)) + + def select_area(self): + """the part of the map that is worth converting, and its bounds. + + Source maps often carry a 3D skybox: a scaled down copy of the map placed + away from the playable area (a sky_camera entity marks where). Converting + it would both add junk and blow up the world so much that the real map + turns coarse, so only the area that is connected to the player spawns (or + the largest area when a map has no spawns) is kept - unless --whole-map + says otherwise. Returns the bounds of the kept area. + """ + boxes = self.face_boxes + if not boxes: + return None + if self.args.whole_map: + self.use_faces = set(self.face_ids) + self.keep_cells = None # nothing is filtered out + self.dropped_faces = 0 + self.area_bounds = self.union_boxes(boxes) + return self.area_bounds + + occupied = set() + members = defaultdict(list) + for k, b in enumerate(boxes): + for i in range(int(math.floor(b[0] / REGION_CELL)), + int(math.floor(b[3] / REGION_CELL)) + 1): + for j in range(int(math.floor(b[1] / REGION_CELL)), + int(math.floor(b[4] / REGION_CELL)) + 1): + occupied.add((i, j)) + members[(int(math.floor((b[0] + b[3]) / 2.0 / REGION_CELL)), + int(math.floor((b[1] + b[4]) / 2.0 / REGION_CELL)))].append(k) + + spawns = [] + for ent in self.bsp.entities(): + if ent.get("classname", "").lower() in SPAWN_CLASSES: + o = parse_vec(ent.get("origin")) + if o: + spawns.append(o) + + seen = set() + areas = [] + for start in occupied: + if start in seen: + continue + seen.add(start) + stack = [start] + cells = [] + while stack: + i, j = stack.pop() + cells.append((i, j)) + for di in (-1, 0, 1): + for dj in (-1, 0, 1): + n = (i + di, j + dj) + if n in occupied and n not in seen: + seen.add(n) + stack.append(n) + faces = [k for c in cells for k in members.get(c, ())] + if faces: + areas.append((faces, cells)) + if not areas: + self.use_faces = set(self.face_ids) + self.keep_cells = None + self.dropped_faces = 0 + self.area_bounds = self.union_boxes(boxes) + return self.area_bounds + + spawnarea = None + for k, (faces, _cells) in enumerate(areas): + b = self.union_boxes([boxes[f] for f in faces]) + inside = sum(1 for s in spawns if b[0] <= s[0] <= b[3] and + b[1] <= s[1] <= b[4] and b[2] <= s[2] <= b[5]) + if inside and (spawnarea is None or + len(faces) > len(areas[spawnarea][0])): + spawnarea = k + best = spawnarea if spawnarea is not None else max( + range(len(areas)), key=lambda k: len(areas[k][0])) + self.keep_cells = set(areas[best][1]) + self.use_faces = set(self.face_ids[f] for f in areas[best][0]) + self.dropped_faces = len(boxes) - len(areas[best][0]) + self.area_bounds = self.union_boxes([boxes[f] for f in areas[best][0]]) + return self.area_bounds + + def keeps(self, box): + """does a piece of geometry belong to the area that was kept?""" + cells = self.keep_cells + if cells is None: + return True + for i in range(int(math.floor(box[0] / REGION_CELL)), + int(math.floor(box[3] / REGION_CELL)) + 1): + for j in range(int(math.floor(box[1] / REGION_CELL)), + int(math.floor(box[4] / REGION_CELL)) + 1): + if (i, j) in cells: + return True + return False + + def choose_scale(self, b): + ext = max(b[3] - b[0], b[4] - b[1]) + zext = b[5] - b[2] + if self.args.scale != "auto": + return float(self.args.scale) + scale = MIN_SCALE + for _ in range(24): + if zext / scale + ZOFF + SKY_HEADROOM + 2 > HEIGHT_LIMIT or \ + ext / scale > self.args.max_cells - 2 * MINBORD - 2: + scale *= 1.15 + else: + break + return min(scale, MAX_SCALE) + + def run(self): + args = self.args + bsp = self.bsp + self.face_boxes = [] + self.face_ids = [] + for f, (_n, poly, _t) in enumerate(bsp.faces): + if len(poly) >= 3: + self.face_boxes.append(self.box(poly)) + self.face_ids.append(f) + b = self.select_area() + if b is None: + raise BspError("the map has no faces at all, there is nothing to convert") + self.bounds = b + scale = self.scale = self.choose_scale(b) + if args.sfactor: + sfactor = args.sfactor + else: + need = max(b[3] - b[0], b[4] - b[1]) / scale + 2 * MINBORD + 2 + sfactor = 6 + while (1 << sfactor) < need and sfactor < MAX_SFACTOR: + sfactor += 1 + sfactor = max(sfactor, args.min_sfactor) + ssize = self.ssize = 1 << sfactor + self.sfactor = sfactor + if (b[3] - b[0]) / scale > ssize - 2 * MINBORD or \ + (b[4] - b[1]) / scale > ssize - 2 * MINBORD: + raise BspError("the map does not fit into a %d cell world at %g units " + "per cube (-s %.2f or --sfactor %d)" + % (ssize, scale, scale, min(MAX_SFACTOR, sfactor + 1))) + if ssize > args.max_cells * 2: + self.warn("the world is %dx%d cells, this is a large conversion" % (ssize, ssize)) + self.x0 = (b[0] + b[3]) / 2.0 - ssize / 2.0 * scale + self.y0 = (b[1] + b[4]) / 2.0 - ssize / 2.0 * scale + self.z0 = b[2] + # the clip window has to reach a bit above the highest geometry, or the + # space above surfaces that come close to it would look too thin to use + zpad = max((args.min_span + 1.0) * scale, scale) + self.zlo = b[2] - zpad + self.zhi = b[5] + zpad + + self.log("%s: bsp version %d, %.0f x %.0f x %.0f source units" + % (os.path.basename(args.bsp), bsp.version, + b[3] - b[0], b[4] - b[1], b[5] - b[2])) + if self.dropped_faces: + self.log("kept the main area of the map; %d faces outside it (a 3D " + "skybox or a detached area) are left out (-w keeps them)" + % self.dropped_faces) + self.log("scale %.2f units/cube, world %dx%d cells (sfactor %d), %d brushes" + % (scale, ssize, ssize, sfactor, len(bsp.brushes))) + if len(bsp.models) > 1: + self.log("%d models in the map: brush entity geometry is baked in " + "its compiled position" % len(bsp.models)) + + self.use_tree = bsp.load_tree() + if not self.use_tree: + self.log("this .bsp has no BSP tree, falling back to tracing brush " + "boxes (trigger volumes can not be told apart then)") + self.prepare_brushes() + self.trace_columns() + self.compute_cell_corners() + self.pick_textures() + self.build_world() + ents = self.build_entities() + self.ambient = self.build_ambient() + out = self.write_cgz(ents) + self.report(out) + + def prepare_brushes(self): + z_abs = max(abs(self.zlo), abs(self.zhi)) + self.brushes = [] + self.brushes_outside = 0 + self.water_planes = [] + for contents, hs in self.bsp.brushes: + if contents & CONTENTS_ORIGIN: + continue # used to place brush entities, not solid in Source + keep = True + if self.keep_cells is not None: + poly = brush_footprint(hs, z_abs) + keep = not poly or self.keeps(self.box([(p[0], p[1], 0.0) + for p in poly])) + if contents & LIQUID: + if keep: + self.water_planes.append(tuple(x for p in hs for x in p)) + continue + if contents & BLOCKING: + if keep: + self.brushes.append(tuple(x for p in hs for x in p)) + else: + self.brushes_outside += 1 + if self.brushes_outside: + self.log("%d of %d brushes are outside the kept area" + % (self.brushes_outside, len(self.bsp.brushes))) + if not self.brushes: + raise BspError("the map contains no blocking brushes") + self.binsize = 8 + self.bins = defaultdict(list) + for idx, flat in enumerate(self.brushes): + hs = [flat[j:j + 4] for j in range(0, len(flat), 4)] + poly = brush_footprint(hs, z_abs) + if not poly: + poly = [((self.x0), (self.y0))] + xs = [((p[0] - self.x0) / self.scale) for p in poly] + ys = [((p[1] - self.y0) / self.scale) for p in poly] + bx = by = self.binsize + for cy in range(int(math.floor(min(ys) / by)) - 1, + int(math.floor(max(ys) / by)) + 2): + for cx in range(int(math.floor(min(xs) / bx)) - 1, + int(math.floor(max(xs) / bx)) + 2): + self.bins[(cx, cy)].append(idx) + + def candidates(self, i, j): + return self.bins.get((i // self.binsize, j // self.binsize), ()) + + # -- pass 1: one open space per column ------------------------------- + + def trace_columns(self): + ssize = self.ssize + n = ssize * ssize + self.state = bytearray(n) # 0 solid, 1 open, 2 open to the bottom + self.floor_src = array("f", bytes(4 * n)) + self.ceil_src = array("f", bytes(4 * n)) + self.corner_own = array("f", bytes(4 * n)) + self.corner_min = array("f", bytes(4 * n)) + self.corner_max = array("f", bytes(4 * n)) + self.corner_avg = array("f", bytes(4 * n)) + self.min_span_src = self.args.min_span * self.scale + self.overhang = self.bottom = 0 + policy = self.args.space + if policy in ("playable", "spawns"): + # "spawns" is the old name for the same thing + if not (self.use_tree and self.trace_playable()): + if self.use_tree: + self.warn("could not work out the playable space of this map, " + "keeping the highest space of every column instead") + self.trace_simple("top") + else: + self.trace_simple(policy) + self.stats["columns_overhanging"] = self.overhang + self.stats["columns_open_bottom"] = self.bottom + self.log("%d open columns (%d had more than one open space, %d were open " + "to the bottom of the world and were filled)" + % (sum(1 for s in self.state if s), self.overhang, self.bottom)) + + def column_air(self, i, j, px, py): + """the open spaces of one column, in source units""" + if self.use_tree: + ivs = solid_segments(self.bsp.column_segments(px, py, self.zlo, self.zhi)) + else: + ivs = column_intervals(self.candidates(i, j), self.brushes, px, py) + if not ivs: + return () + return [s for s in air_spans(ivs, self.zlo, self.zhi) + if s[1] - s[0] >= self.min_span_src] + + def open_at(self, x, y, z): + """is the map open (not solid) at this point?""" + return not (self.bsp.leaf_contents(x, y, z) & BLOCKING_LEAF) + + def connected(self, midx, midy, lo, hi): + """do two spaces of neighbouring columns really touch? + + They have to overlap, and the map has to be open where they meet - a + thin wall between two rooms must not turn into a door. + """ + h = hi - lo + return (self.open_at(midx, midy, lo + h * 0.34) or + self.open_at(midx, midy, lo + h * 0.66)) + + def trace_playable(self): + """keep the space a player can actually be in, not the top of the map. + + Every open space of every column is a node, two spaces of neighbouring + columns are joined when they overlap enough and the map is open where + they meet. The component that holds the player spawns (or the biggest + one when a map has no spawns) is what gets converted, so roofs, the sky + above the map and sealed off rooms stay solid - that is what makes a + converted map still read like the original instead of a landscape of + wall tops you can walk on. + """ + ssize, scale = self.ssize, self.scale + n = ssize * ssize + clearance = CONNECT_CLEARANCE * scale + max_step = self.args.max_step * scale + ivlo = array("f", bytes(4 * MAXCOL * n)) + ivhi = array("f", bytes(4 * MAXCOL * n)) + cnt = bytearray(n) + overhang = 0 + for j in range(ssize): + pyc = self.y0 + (j + 0.5) * scale + for i in range(ssize): + spans = self.column_air(i, j, self.x0 + (i + 0.5) * scale, pyc) + if not spans: + continue + if len(spans) > 1: + overhang += 1 + idx = j * ssize + i + k = 0 + for lo, hi in spans[:MAXCOL]: + ivlo[idx * MAXCOL + k] = lo + ivhi[idx * MAXCOL + k] = hi + k += 1 + cnt[idx] = k + self.overhang = overhang + + # which spaces of which neighbouring columns connect, and which way + connR = array("H", bytes(2 * n)) + connRb = array("H", bytes(2 * n)) + connD = array("H", bytes(2 * n)) + connDb = array("H", bytes(2 * n)) + for j in range(ssize): + pyc = self.y0 + (j + 0.5) * scale + for i in range(ssize): + idx = j * ssize + i + ca = cnt[idx] + if not ca: + continue + px = self.x0 + (i + 0.5) * scale + if i + 1 < ssize and cnt[idx + 1]: + connR[idx], connRb[idx] = self.link_masks( + idx, idx + 1, ca, cnt[idx + 1], ivlo, ivhi, clearance, + px + scale * 0.5, pyc, max_step) + if j + 1 < ssize and cnt[idx + ssize]: + connD[idx], connDb[idx] = self.link_masks( + idx, idx + ssize, ca, cnt[idx + ssize], ivlo, ivhi, + clearance, px, pyc + scale * 0.5, max_step) + + # label the components + nodes = n * MAXCOL + label = array("i", bytes(4 * nodes)) + areas = [0.0] + stack = [] + for j in range(ssize): + for i in range(ssize): + idx = j * ssize + i + for k in range(cnt[idx]): + node = idx * MAXCOL + k + if label[node]: + continue + label[node] = len(areas) + areas.append(0.0) + stack.append(node) + area = 0.0 + while stack: + cur = stack.pop() + cidx, kk = divmod(cur, MAXCOL) + ci, cj = cidx % ssize, cidx // ssize + area += ivhi[cur] - ivlo[cur] + if ci + 1 < ssize and connR[cidx]: + m = connR[cidx] + for b in range(cnt[cidx + 1]): + if (m >> (kk * MAXCOL + b)) & 1: + nn = (cidx + 1) * MAXCOL + b + if not label[nn]: + label[nn] = len(areas) - 1 + stack.append(nn) + if ci and connRb[cidx - 1]: + m = connRb[cidx - 1] + for a in range(cnt[cidx - 1]): + if (m >> (a * MAXCOL + kk)) & 1: + nn = (cidx - 1) * MAXCOL + a + if not label[nn]: + label[nn] = len(areas) - 1 + stack.append(nn) + if cj + 1 < ssize and connD[cidx]: + m = connD[cidx] + for b in range(cnt[cidx + ssize]): + if (m >> (kk * MAXCOL + b)) & 1: + nn = (cidx + ssize) * MAXCOL + b + if not label[nn]: + label[nn] = len(areas) - 1 + stack.append(nn) + if cj and connDb[cidx - ssize]: + m = connDb[cidx - ssize] + for a in range(cnt[cidx - ssize]): + if (m >> (a * MAXCOL + kk)) & 1: + nn = (cidx - ssize) * MAXCOL + a + if not label[nn]: + label[nn] = len(areas) - 1 + stack.append(nn) + areas[label[node]] = area + + # the component the spawns are in, or the biggest one + votes = defaultdict(int) + spawns = defaultdict(list) + for ent in self.bsp.entities(): + if ent.get("classname", "").lower() not in SPAWN_CLASSES: + continue + o = parse_vec(ent.get("origin")) + if not o: + continue + i = int(math.floor((o[0] - self.x0) / scale)) + j = int(math.floor((o[1] - self.y0) / scale)) + if not (0 <= i < ssize and 0 <= j < ssize): + continue + idx = j * ssize + i + for k in range(cnt[idx]): + if ivlo[idx * MAXCOL + k] - 1 <= o[2] <= ivhi[idx * MAXCOL + k] + 1: + votes[label[idx * MAXCOL + k]] += 1 + spawns[idx].append(o[2]) + break + if votes: + best = max(votes, key=lambda l: votes[l]) + else: + best = max(range(1, len(areas)), key=lambda l: areas[l], default=0) + if best <= 0: + return False + + # one space per column: the lowest one of that component, so the map keeps + # its ground level (a spawn point always keeps the space it is in) + state, floor_src, ceil_src = self.state, self.floor_src, self.ceil_src + zlo = self.zlo + cols = 0 + for j in range(ssize): + base = j * ssize + for i in range(ssize): + idx = base + i + pick = -1 + spawn_here = spawns.get(idx) + for k in range(cnt[idx]): + node = idx * MAXCOL + k + if label[node] != best: + continue + lo, hi = ivlo[node], ivhi[node] + if spawn_here is not None and any(lo - 1 <= z <= hi + 1 + for z in spawn_here): + pick = k + break + if pick < 0 or lo < ivlo[idx * MAXCOL + pick]: + pick = k + if pick < 0: + continue + node = idx * MAXCOL + pick + flo, cei = ivlo[node], ivhi[node] + cols += 1 + if flo <= zlo + 1e-3: + state[idx] = 2 + self.bottom += 1 + continue + state[idx] = 1 + floor_src[idx] = flo + ceil_src[idx] = cei + opencols = sum(1 for i in range(n) if cnt[i]) + if cols < max(48, opencols // 100): + return False + self.log("playable space: %d columns%s (out of %d open ones)" + % (cols, " around the spawns" if votes else " (the biggest one)", + opencols)) + return True + + def link_masks(self, ia, ib, ca, cb, ivlo, ivhi, clearance, midx, midy, + max_step): + """how the spaces of two neighbouring columns connect. + + Returns (forward, backward) masks: which space of column ia touches which + space of column ib, and which way the player can actually get across - + dropping down any distance is fine, climbing is not. + """ + fwd = back = 0 + for a in range(ca): + alo, ahi = ivlo[ia * MAXCOL + a], ivhi[ia * MAXCOL + a] + for b in range(cb): + blo, bhi = ivlo[ib * MAXCOL + b], ivhi[ib * MAXCOL + b] + lo, hi = max(alo, blo), min(ahi, bhi) + if hi - lo < clearance: + continue + if not self.connected(midx, midy, lo, hi): + continue + if blo - alo <= max_step: + fwd |= 1 << (a * MAXCOL + b) + if alo - blo <= max_step: + back |= 1 << (b * MAXCOL + a) + return fwd, back + + def trace_simple(self, policy): + """one open space per column, picked by policy (no connectivity)""" + ssize, scale = self.ssize, self.scale + zlo = self.zlo + state, floor_src, ceil_src = self.state, self.floor_src, self.ceil_src + spawns = defaultdict(list) + if policy == "spawns": + for ent in self.bsp.entities(): + if ent.get("classname", "").lower() in SPAWN_CLASSES: + o = parse_vec(ent.get("origin")) + if o: + spawns[(int((o[0] - self.x0) / scale), + int((o[1] - self.y0) / scale))].append(o[2]) + for j in range(ssize): + pyc = self.y0 + (j + 0.5) * scale + base = j * ssize + for i in range(ssize): + usable = self.column_air(i, j, self.x0 + (i + 0.5) * scale, pyc) + if not usable: + continue + if policy == "largest": + chosen = max(usable, key=lambda s: s[1] - s[0]) + elif policy == "spawns" and spawns.get((i, j)): + cs = spawns[(i, j)] + chosen = max(usable, key=lambda s: sum( + 1 for z in cs if s[0] - 1 <= z <= s[1] + 1)) + else: + chosen = usable[-1] + flo, cei = chosen + idx = base + i + if flo <= zlo + 1e-3: + state[idx] = 2 + self.bottom += 1 + continue + state[idx] = 1 + floor_src[idx] = flo + ceil_src[idx] = cei + + def compute_cell_corners(self): + """Corner heights from the cell surfaces instead of the geometry itself. + + A heightfield vertex is shared by up to four cells, and the engine reads + it from the cell it belongs to, so it has to be the same for all of them. + The average of the surrounding cell surfaces gives exactly the surface + height at that corner for sloped (linear) geometry.""" + ssize, scale = self.ssize, self.scale + stride = ssize + 1 + state, floor_src = self.state, self.floor_src + vh = array("f", bytes(4 * stride * stride)) + filled = bytearray(stride * stride) + offsets = ((-1, -1), (0, -1), (-1, 0), (0, 0)) + for j in range(stride): + row = j * stride + for i in range(stride): + s = 0.0 + cnt = 0 + for di, dj in offsets: + ii, jj = i + di, j + dj + if 0 <= ii < ssize and 0 <= jj < ssize: + idx = jj * ssize + ii + if state[idx] == 1: + s += floor_src[idx] + cnt += 1 + if cnt: + vh[row + i] = s / cnt + filled[row + i] = 1 + own, cmin, cmax, cavg = (self.corner_own, self.corner_min, + self.corner_max, self.corner_avg) + for j in range(ssize): + for i in range(ssize): + idx = j * ssize + i + if state[idx] != 1: + continue + cs = [] + for di, dj in ((0, 0), (1, 0), (0, 1), (1, 1)): + k = (j + dj) * stride + (i + di) + cs.append(vh[k] if filled[k] else floor_src[idx]) + own[idx] = cs[0] + cmin[idx] = min(cs) + cmax[idx] = max(cs) + cavg[idx] = sum(cs) * 0.25 + self.vheight = vh + self.vfilled = filled + self.vstride = stride + + # -- pass 2: textures ------------------------------------------------- + + def pick_textures(self): + ssize, scale = self.ssize, self.scale + n = ssize * ssize + bsp = self.bsp + mats = bsp.face_materials() + self.floor_mat = array("i", bytes(4 * n)) + self.ceil_mat = array("i", bytes(4 * n)) + self.wall_mat = array("i", bytes(4 * n)) + self.floor_err = array("f", struct.pack("= 0.25: + self.raster_horizontal(poly, nx, ny, nz, dist, name, + "floor" if nz > 0 else "ceil") + else: + self.raster_wall(poly, name) + + def cell_range(self, poly, pad=0.0): + """the world cells a face can touch, or None when it misses the world""" + s = self.scale + xs = [(p[0] - self.x0) / s for p in poly] + ys = [(p[1] - self.y0) / s for p in poly] + i0 = int(math.floor(min(xs) - pad)) + i1 = int(math.ceil(max(xs) + pad)) + j0 = int(math.floor(min(ys) - pad)) + j1 = int(math.ceil(max(ys) + pad)) + if i1 < 0 or j1 < 0 or i0 > self.ssize - 1 or j0 > self.ssize - 1: + return None + return (max(0, i0), min(self.ssize - 1, i1), + max(0, j0), min(self.ssize - 1, j1)) + + def raster_horizontal(self, poly, nx, ny, nz, dist, name, role): + ssize, s = self.ssize, self.scale + state = self.state + r = self.cell_range(poly) + if r is None: + return + i0, i1, j0, j1 = r + slot = self.textures.slot(name, role) + pts = [((p[0] - self.x0) / s, (p[1] - self.y0) / s) for p in poly] + level = self.floor_src if role == "floor" else self.ceil_src + err = self.floor_err if role == "floor" else self.ceil_err + mat = self.floor_mat if role == "floor" else self.ceil_mat + n = len(pts) + for j in range(j0, j1 + 1): + yc = j + 0.5 + xs = [] + for k in range(n): + x1, y1 = pts[k] + x2, y2 = pts[(k + 1) % n] + if (y1 <= yc < y2) or (y2 <= yc < y1): + xs.append(x1 + (x2 - x1) * (yc - y1) / (y2 - y1)) + if len(xs) < 2: + continue + xs.sort() + for k in range(0, len(xs) - 1, 2): + ia = max(i0, int(math.ceil(xs[k] - 0.5))) + ib = min(i1, int(math.floor(xs[k + 1] - 0.5))) + for i in range(ia, ib + 1): + idx = j * ssize + i + if state[idx] != 1: + continue + xw = self.x0 + (i + 0.5) * s + yw = self.y0 + (j + 0.5) * s + d = abs((dist - nx * xw - ny * yw) / nz - level[idx]) + if d < err[idx] and d <= 1.5 * s: + err[idx] = d + mat[idx] = slot + + def raster_wall(self, poly, name): + ssize, s = self.ssize, self.scale + state = self.state + flat = [((p[0] - self.x0) / s, (p[1] - self.y0) / s) for p in poly] + zs = [p[2] for p in poly] + ax, ay = flat[0] + bx, by = flat[-1] + length = math.hypot(bx - ax, by - ay) + area = (max(zs) - min(zs)) * max(length, s) + if area <= 0: + return + slot = self.textures.slot(name, "wall") + r = self.cell_range(poly, pad=1.5) + if r is None: + return + i0, i1, j0, j1 = r + dx, dy = bx - ax, by - ay + len2 = dx * dx + dy * dy + for j in range(j0, j1 + 1): + for i in range(i0, i1 + 1): + idx = j * ssize + i + if state[idx] != 1: + continue + px, py = i + 0.5, j + 0.5 + if len2 > 1e-9: + t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / len2)) + d = math.hypot(px - (ax + t * dx), py - (ay + t * dy)) + else: + d = math.hypot(px - ax, py - ay) + if d > 1.0: + continue + if max(zs) < self.floor_src[idx] - 0.5 or \ + min(zs) > self.ceil_src[idx] + 0.5: + continue + if area > self.wall_w[idx]: + self.wall_w[idx] = area + self.wall_mat[idx] = slot + + # -- pass 3: the cube world ------------------------------------------- + + def build_world(self): + ssize, scale = self.ssize, self.scale + n = ssize * ssize + state, own = self.state, self.corner_own + z0, zc = self.z0, lambda z: (z - self.z0) / self.scale + ZOFF + floor_ac = array("f", bytes(4 * n)) + ceil_ac = array("f", bytes(4 * n)) + sloped = bytearray(n) + vmax = -1e30 + for idx in range(n): + if state[idx] != 1: + continue + floor_ac[idx] = zc(self.corner_min[idx]) + ceil_ac[idx] = zc(self.ceil_src[idx]) + if self.corner_max[idx] > vmax: + vmax = self.corner_max[idx] + if (self.corner_max[idx] - self.corner_min[idx]) / scale > 0.03: + sloped[idx] = 1 + if vmax < -1e29: + raise BspError("no open space found, there is nothing to convert") + base = self.base = int(math.ceil(zc(vmax))) + if base + SKY_HEADROOM + 1 > HEIGHT_LIMIT: + raise BspError("the map is too tall for AC (%d cubes); use a coarser " + "scale (-s %.1f or more)" + % (base, (self.bounds[5] - self.bounds[2]) / + (HEIGHT_LIMIT - SKY_HEADROOM - 6.0))) + + smooth = not self.args.no_smooth + dipcells = 0 + if smooth: + read = bytearray(n) + for j in range(ssize): + for i in range(ssize): + if not sloped[j * ssize + i]: + continue + for dj in (-1, 0, 1): + for di in (-1, 0, 1): + ii, jj = i + di, j + dj + if 0 <= ii < ssize and 0 <= jj < ssize and \ + state[jj * ssize + ii] == 1: + read[jj * ssize + ii] = 1 + low = min((self.corner_min[i] for i in range(n) if read[i]), default=None) + if low is not None and base - zc(low) > SMOOTH_LIMIT: + dipcells = sum(1 for i in range(n) if read[i]) + smooth = False + best = math.ceil((self.bounds[5] - self.bounds[2]) / SMOOTH_LIMIT) + self.warn("the walkable surface drops %.0f cubes from its highest " + "point (the AC heightfield limit is %.0f), so ramps are " + "stair-stepped here. Pass -s %d (or more) for smooth " + "ramps - cubes get bigger and the map gets lower detail." + % (base - zc(low), SMOOTH_LIMIT, max(16, int(best)))) + self.log("world %s, floor level %d, heightfields %s" + % ("smoothed" if smooth else "stair-stepped", base, + "on" if smooth else "off")) + + vh, vfilled, vstride = self.vheight, self.vfilled, self.vstride + + def vdelta_at(i, j): + """a cube's own corner vdelta; solid cubes carry one as well, because + the heightfield cell next to them reads it to place that corner""" + if not smooth: + return 0 + k = j * vstride + i + if not vfilled[k]: + return 0 + v = int(round(4.0 * (base - zc(vh[k])))) + return 0 if v < 0 else (255 if v > 255 else v) + + def cube(idx): + """the file representation of one cube""" + i, j = idx % ssize, idx // ssize + wall = int(self.wall_mat[idx]) or DEFAULT_WALL + if state[idx] != 1: + return (SOLID, 0, 0, wall, 0, 0, vdelta_at(i, j), 0, 0) + ftex = int(self.floor_mat[idx]) or DEFAULT_FLOOR + skyceil = self.ceil_src[idx] >= self.zhi - 1e-3 + ctex = DEFAULT_SKY if skyceil else (int(self.ceil_mat[idx]) or DEFAULT_CEIL) + c = int(round(ceil_ac[idx])) + if skyceil: + # a sky column keeps its own headroom, and a heightfield cell has + # to keep its ceiling above the base it stores as its floor + c = max(c, int(round(zc(self.corner_avg[idx]) + SKY_HEADROOM)), base + 1) + if smooth and sloped[idx] and c > base: + return (FHF, base, min(c, HEIGHT_LIMIT), wall, ftex, ctex, + vdelta_at(i, j), wall, 0) + f = int(round(zc(self.corner_avg[idx]))) + f = max(1, min(f, HEIGHT_LIMIT - 1)) + c = max(c, f + 1) + return (SPACE, f, min(c, HEIGHT_LIMIT), wall, ftex, ctex, + vdelta_at(i, j), wall, 0) + + self.log("materials in use: %d floor, %d ceiling, %d wall" + % (len(set(self.floor_mat)), len(set(self.ceil_mat)), + len(set(self.wall_mat)))) + self.cubedata = self.encode(cube) + + def encode(self, cube): + ssize = self.ssize + out = bytearray() + sc = 0 + prev = None + + def purge(): + nonlocal sc + while sc: + out.append(255) + if sc > 255: + out.append(255) + sc -= 255 + else: + out.append(sc) + sc = 0 + + counts = self.stats + for j in range(ssize): + base = j * ssize + for i in range(ssize): + idx = base + i + if i < MINBORD or j < MINBORD or i >= ssize - MINBORD or \ + j >= ssize - MINBORD: + c = (SOLID, 0, 0, DEFAULT_WALL, 0, 0, 0, 0, 0) + else: + c = cube(idx) + counts[CUBE_TYPE_NAMES[c[0]]] += 1 + if c[0] == SOLID: + if prev is not None and prev[0] == SOLID and \ + prev[3] == c[3] and prev[6] == c[6]: + sc += 1 + else: + purge() + out.append(SOLID) + out.append(c[3]) + out.append(c[6]) + else: + if prev is not None and prev == c: + sc += 1 + else: + purge() + out.append(c[0]) + out.append(c[1] & 0xff) + out.append(c[2] & 0xff) + out.append(c[3] & 0xff) + out.append(c[4] & 0xff) + out.append(c[5] & 0xff) + out.append(c[6] & 0xff) + out.append(c[7] & 0xff) + out.append(c[8] & 0xff) + prev = c + purge() + return out + + # -- entities --------------------------------------------------------- + + def build_entities(self): + args = self.args + if args.entities == "none": + return [] + ssize, scale = self.ssize, self.scale + ents = [] + ffa = teams = skipped = 0 + for ent in self.bsp.entities(): + cls = ent.get("classname", "").lower() + if cls not in SPAWN_CLASSES: + continue + o = parse_vec(ent.get("origin")) + if not o: + skipped += 1 + continue + i = int(math.floor((o[0] - self.x0) / scale)) + j = int(math.floor((o[1] - self.y0) / scale)) + if not (0 <= i < ssize and 0 <= j < ssize): + skipped += 1 + continue + idx = j * ssize + i + if self.state[idx] != 1 or not (self.floor_src[idx] - 1.0 <= o[2] + <= self.ceil_src[idx] + 1.0): + skipped += 1 + continue + z = int(round(self.corner_avg[idx] / scale - self.z0 / scale + ZOFF + 4.5)) + zmax = int(round((self.ceil_src[idx] - self.z0) / scale + ZOFF)) - 1 + z = max(1, min(z, max(1, zmax))) + yaw = 0.0 + if "angles" in ent: + a = ent["angles"].split() + if len(a) >= 2: + try: + yaw = float(a[1]) + except ValueError: + yaw = 0.0 + elif "angle" in ent: + try: + yaw = float(ent["angle"]) + except ValueError: + yaw = 0.0 + attr1 = int(round(((yaw + 90.0) % 360.0) * 10.0)) % 3600 + team = SPAWN_CLASSES[cls] + ents.append(self.entity(i, j, z, attr1, E_PLAYERSTART, team)) + if team == 100: + ffa += 1 + else: + teams += 1 + if skipped: + self.log("%d spawn entities were dropped (outside the world or in a " + "space that was not kept)" % skipped) + lights = self.build_lights() if args.entities == "all" else [] + self.stats["lights"] = len(lights) + ents.extend(lights) + if args.spawn_fill and ffa < 5: + added = self.fill_spawns(ents, 5 - ffa) + ffa += added + if added: + self.log("added %d FFA spawn points so the map is playable" % added) + self.stats["spawns_ffa"] = ffa + self.stats["spawns_team"] = teams + if ffa + teams == 0: + self.warn("no usable spawn points; the map can only be looked at") + self.log("%d spawn entities: %d free for all, %d team" % (ffa + teams, ffa, teams)) + return ents + + @staticmethod + def entity(i, j, z, attr1, typ, a2, a3=0, a4=0, a5=0, a6=0, a7=0): + return (i, j, z, attr1, typ, a2, a3, a4, a5, a6, a7) + + def build_ambient(self): + """the map's base light (the ambient field of the AC map header). + + AssaultCube lights a map from its LIGHT entities plus this base value, so + a converted map that only kept the geometry would be almost black. The + map's sun/sky (light_environment) is the closest thing Source has to a + base light, so its colour is used for it. + """ + if self.args.ambient != "auto": + try: + return max(0, min(0xFFFFFF, int(self.args.ambient, 0))) + except ValueError: + self.warn("--ambient %r is not a number, using auto" + % self.args.ambient) + col = [255, 246, 232] + bright = 200.0 + source = "default daylight" + for ent in self.bsp.entities(): + if ent.get("classname", "").lower() != "light_environment": + continue + a = (ent.get("_ambient") or "").split() or (ent.get("_light") or "").split() + try: + vals = [abs(float(v)) for v in a[:4]] + except ValueError: + continue + if len(vals) >= 3 and max(vals[:3]) > 0: + col = vals[:3] + bright = vals[3] if len(vals) >= 4 else 200.0 + source = "the map's sky/sun" + break + # a daylight map reads well around a third of full brightness + level = A_AMBIENT_BASE + int(round(A_AMBIENT_SUN * min(bright, 400.0) / 400.0)) + m = max(col) or 1.0 + r = min(255, int(round(level * col[0] / m))) + g = min(255, int(round(level * col[1] / m))) + b = min(255, int(round(level * col[2] / m))) + self.log("ambient light 0x%02x%02x%02x (from %s)" % (r, g, b, source)) + return (r << 16) | (g << 8) | b + + def build_lights(self): + """Source lights as AC LIGHT entities: radius in cubes, then the colour + (AC's coloured light form, attr2..4 = r-g-b)""" + ents = [] + ssize, scale = self.ssize, self.scale + default_reach = max(2, self.args.light_reach) + for ent in self.bsp.entities(): + cls = ent.get("classname", "").lower() + if cls == "light_environment": + continue # the sun became the map's ambient light + if cls not in ("light", "light_spot"): + continue + o = parse_vec(ent.get("origin")) + if not o: + continue + i = int(math.floor((o[0] - self.x0) / scale)) + j = int(math.floor((o[1] - self.y0) / scale)) + if not (0 <= i < ssize and 0 <= j < ssize): + continue + idx = j * ssize + i + if self.state[idx] != 1: + continue + zmin = int(round((self.floor_src[idx] - self.z0) / scale + ZOFF)) + zmax = int(round((self.ceil_src[idx] - self.z0) / scale + ZOFF)) + z = int(round((o[2] - self.z0) / scale + ZOFF)) + z = max(zmin, min(z, zmax)) + col = [255, 255, 255] + vals = [] + for v in (ent.get("_light") or "").split()[:4]: + try: + vals.append(abs(float(v))) + except ValueError: + pass + if len(vals) >= 3: + col = [max(0, min(255, int(round(v)))) for v in vals[:3]] + if max(col) == 0: + col = [255, 255, 255] + reach = 0.0 + for key in ("_distance", "_lightradius", "radius"): + r = parse_vec(ent.get(key)) + if r and r[0] > 0: + reach = r[0] + break + rad = default_reach if reach <= 0 else int(round(reach / scale)) + rad = max(2, min(255, rad)) + if col[0] == col[1] == col[2]: + attrs = (col[0], 0, 0) # AC's plain white light form + else: + attrs = tuple(col) # coloured light + ents.append(self.entity(i, j, z, rad, E_LIGHT, attrs[0], attrs[1], + attrs[2])) + if ents: + self.log("%d light entities (%d cubes of reach by default)" + % (len(ents), default_reach)) + else: + self.warn("the map has no lights that could be converted, so it is " + "lit by its ambient light alone (-a/--ambient sets it)") + return ents + + def fill_spawns(self, ents, count): + ssize = self.ssize + step = max(4, ssize // 12) + cand = [] + scale = self.scale + for j in range(MINBORD + 2, ssize - MINBORD - 2, step): + for i in range(MINBORD + 2, ssize - MINBORD - 2, step): + idx = j * ssize + i + if self.state[idx] != 1 or \ + self.ceil_src[idx] - self.floor_src[idx] < 3 * scale: + continue + # only flat, open spots, so spawns do not end up on wall tops + h = self.floor_src[idx] + for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)): + ni, nj = i + di, j + dj + nidx = nj * ssize + ni + if not (0 <= ni < ssize and 0 <= nj < ssize) or \ + self.state[nidx] != 1 or \ + abs(self.floor_src[nidx] - h) > scale: + break + else: + cand.append((i, j)) + used = [(e[0], e[1]) for e in ents] + added = 0 + for (i, j) in cand: + if added >= count: + break + if used and min(max(abs(i - u[0]), abs(j - u[1])) for u in used) < step: + continue + idx = j * ssize + i + z = int(round((self.corner_avg[idx] - self.z0) / self.scale + ZOFF + 4.5)) + ents.append(self.entity(i, j, z, 0, E_PLAYERSTART, 100)) + used.append((i, j)) + added += 1 + return added + + # -- writer ----------------------------------------------------------- + + def waterlevel(self): + if not self.water_planes: + return -10000 * 10 + tops = [] + for flat in self.water_planes: + for k in range(0, len(flat), 4): + nz = flat[k + 2] + if nz > 1e-6: + tops.append(flat[k + 3] / nz) + if not tops: + return -10000 * 10 + top = (max(tops) - self.z0) / self.scale + ZOFF + return int(round(top * 10)) + + def write_cgz(self, ents): + args = self.args + t0 = time.time() + water = self.waterlevel() + title = (args.title or ("%s (source conversion)" % args.name))[:127] + head = struct.pack("<4s4i", b"ACMP", 10, 980, self.sfactor, len(ents)) + head += title.encode("latin-1", "replace").ljust(128, b"\0")[:128] + head += bytes(range(256)) * 3 + head += struct.pack("= MAXMAPSENDSIZE: + self.warn("the file is %d bytes and AC only sends maps below %d bytes to " + "players, so servers can not hand this map out (local and bot " + "play are fine)" % (len(comp), MAXMAPSENDSIZE)) + if water > -10000 * 10 and water > 0: + self.log("water level %.1f cubes" % (water / 10.0)) + return out + + def report(self, path): + errs = verify(path) + self.log("self check: %s" % ("ok" if not errs else "; ".join(errs))) + self.log("cubes: %s" + % ", ".join("%s %d" % (k, self.stats[k]) for k in CUBE_TYPE_NAMES + if k in self.stats)) + self.log("ambient light 0x%06x, %d lights" + % (getattr(self, "ambient", 0), self.stats["lights"])) + if self.warnings: + self.log("") + self.log("%d warning(s):" % len(self.warnings)) + for w in self.warnings: + self.log(" * %s" % w) + + +# --------------------------------------------------------------- verifying + +def verify(path): + """independent decode of the written file; returns a list of problems""" + problems = [] + try: + raw = gzip.open(path, "rb").read() + except OSError as e: + return ["could not read the written file: %s" % e] + if len(raw) < 980: + return ["file shorter than a map header"] + magic, version, headersize, sfactor, numents = struct.unpack_from("<4s4i", raw, 0) + if magic not in (b"ACMP", b"CUBE"): + problems.append("bad map magic %r" % magic) + if version != 10: + problems.append("bad version %d" % version) + if not (6 <= sfactor <= 11): + problems.append("bad sfactor %d" % sfactor) + if headersize != 980: + problems.append("unexpected header size %d" % headersize) + ssize = 1 << sfactor + waterlevel = struct.unpack_from(" len(raw): + problems.append("entity %d is out of bounds" % i) + break + x, y, z, _a1 = struct.unpack_from("<4h", raw, off) + typ = raw[off + 8] + if typ not in (E_LIGHT, E_PLAYERSTART): + problems.append("entity %d has type %d" % (i, typ)) + if not (0 <= x < ssize and 0 <= y < ssize): + problems.append("entity %d is at %d,%d outside the world" % (i, x, y)) + off += 16 + geo = raw[off:] + n = len(geo) + p = 0 + i = 0 + prev = None + counts = defaultdict(int) + while i < ssize * ssize: + if p >= n: + problems.append("geometry ended after %d of %d cubes" % (i, ssize * ssize)) + break + t = geo[p] + p += 1 + if t == 255: + if p >= n: + problems.append("truncated run") + break + cnt = geo[p] + p += 1 + if prev is None: + problems.append("run before the first cube") + break + counts[prev[0]] += cnt + i += cnt + continue + if t == SOLID: + if p + 2 > n: + problems.append("truncated solid cube") + break + prev = (SOLID, 0, 16, geo[p], DEFAULT_FLOOR, DEFAULT_CEIL, geo[p + 1], + geo[p], 0) + p += 2 + else: + if t >= 5: + problems.append("cube %d has type %d" % (i, t)) + break + if p + 8 > n: + problems.append("truncated cube") + break + f = geo[p] + ce = geo[p + 1] + if f > 127: + f -= 256 + if ce > 127: + ce -= 256 + if f >= ce: + problems.append("cube %d has floor %d >= ceil %d" % (i, f, ce)) + break + prev = (t, f, ce, geo[p + 2], geo[p + 3], geo[p + 4], geo[p + 5], + geo[p + 6], geo[p + 7]) + p += 8 + counts[prev[0]] += 1 + i += 1 + if i != ssize * ssize: + problems.append("decoded %d cubes, expected %d" % (i, ssize * ssize)) + if p != n: + problems.append("%d trailing bytes after the geometry" % (n - p)) + print(" cubes: " + ", ".join("%s %d" % (CUBE_TYPE_NAMES[k], v) + for k, v in sorted(counts.items())), file=sys.stderr) + print(" entities: %d, water level %d" % (numents, waterlevel), file=sys.stderr) + return problems + + +# --------------------------------------------------------------- main + +def sanitize(name): + name = re.sub(r"\.[^.]*$", "", os.path.basename(name)) + name = re.sub(r"[^A-Za-z0-9_-]", "_", name) + return (name or "converted")[:24] + + +def main(argv=None): + ap = argparse.ArgumentParser( + description="convert a Source engine .bsp map into an AssaultCube .cgz map", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="examples:\n" + " %(prog)s surf_ski_2.bsp\n" + " %(prog)s -s 21.33 --space spawns --lights de_dust2.bsp\n") + ap.add_argument("bsp", help="the Source .bsp file to convert") + ap.add_argument("-o", "--output", help="output .cgz path (default: " + "/.cgz)") + ap.add_argument("--outputdir", default="packages/maps", + help="where to put the map (default: packages/maps)") + ap.add_argument("--name", help="map name, also the output file name") + ap.add_argument("--title", help="map title (max 127 characters)") + ap.add_argument("-s", "--scale", default="auto", + help="source units per cube or 'auto' (default). AC maps use " + "about 10.67 (8 source units); bigger values make the " + "world coarser but fit taller maps") + ap.add_argument("--sfactor", type=int, default=0, help="force the world size: " + "6..11 for 64..2048 cells") + ap.add_argument("--min-sfactor", type=int, default=6, help=argparse.SUPPRESS) + ap.add_argument("--max-cells", type=int, default=512, + help="keep the world at most this many cells wide when picking " + "a scale automatically (default 512)") + ap.add_argument("-w", "--whole-map", dest="whole_map", action="store_true", + help="convert every area in the .bsp, including outlying ones " + "such as a 3D skybox (default: only the area the player " + "spawns are in, or the largest one if there are none)") + ap.add_argument("--space", choices=("playable", "top", "spawns", "largest"), + default="playable", + help="which open space above a column survives: 'playable' " + "(default) is the connected space around the player " + "spawns, so roofs, the sky and sealed off rooms stay " + "solid; 'top' keeps the highest space of every column " + "(what surf and aim maps want), 'largest' the tallest " + "space ('spawns' is the old name for 'playable')") + ap.add_argument("--min-span", type=float, default=2.0, + help="ignore open spaces thinner than this many cubes " + "(default 2)") + ap.add_argument("--max-step", type=float, default=2.0, + help="how high the player can climb between two spaces, in " + "cubes, when working out what is reachable; dropping " + "down is always allowed (default 2)") + ap.add_argument("--entities", choices=("none", "spawns", "all"), default="all", + help="none: geometry only, spawns: geometry + player spawns, " + "all (default): also convert the lights") + ap.add_argument("--lights", dest="entities", action="store_const", const="all", + help="alias for --entities all (the default)") + ap.add_argument("-a", "--ambient", default="auto", + help="the map's base light: 'auto' (default) takes it from the " + "map's sun/sky, otherwise an 0xRRGGBB or decimal value " + "(0 keeps AssaultCube's very dark default)") + ap.add_argument("--light-reach", type=int, default=32, + help="radius in cubes for lights that have no distance set " + "(default 32; AssaultCube's own maps use 2 to 20, but " + "they are much smaller than a converted Source map)") + ap.add_argument("--no-spawn-fill", dest="spawn_fill", action="store_false", + help="do not add spawn points if the map has fewer than 5") + ap.add_argument("--no-smooth", dest="no_smooth", action="store_true", + help="never use heightfields, always write stair steps") + ap.add_argument("--actextures", help="path to config/default_map_settings.cfg, " + "used to map source materials to AC texture " + "slots") + ap.add_argument("--revision", type=int, default=1, help=argparse.SUPPRESS) + args = ap.parse_args(argv) + + def log(msg, _to=sys.stderr): + print(msg, file=_to) + + if not os.path.exists(args.bsp): + log("error: %s does not exist" % args.bsp) + return 1 + args.name = sanitize(args.name or args.bsp) + if not re.match(r"^[A-Za-z0-9_-]+$", args.name): + log("error: map name %r may only contain letters, digits, - and _" % args.name) + return 1 + try: + bsp = Bsp(args.bsp) + bsp.load_geometry() + conv = Converter(bsp, args, log) + conv.textures = AcTextures(find_actextures(args.actextures)) + if conv.textures.names: + log("%d textures known from config/default_map_settings.cfg" + % len(conv.textures.names)) + else: + log("no texture list found, using the default AC textures") + conv.run() + except BspError as e: + log("error: %s" % e) + return 1 + except KeyboardInterrupt: + return 130 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/source/src/bot/ac_bot.cpp b/source/src/bot/ac_bot.cpp index 56d161a0..43bce1a6 100644 --- a/source/src/bot/ac_bot.cpp +++ b/source/src/bot/ac_bot.cpp @@ -62,6 +62,33 @@ void CACBot::Spawn() m_vGoal = m_vWaterGoal = g_vecZero; + m_iLastHealth = m_pMyEnt->health; + m_iDodgeTime = 0; + m_iDodgeDir = DIR_NONE; + m_iGrenadeDelay = 0; + m_iStrafeSide = DIR_NONE; + m_iStrafeSideTime = 0; + m_iSteerSide = DIR_NONE; + m_iSteerSideTime = 0; + m_iSteerDir = DIR_NONE; + m_bStrafeRunning = false; + m_iLastDeathSeen = 0; + m_iSwearTime = 0; + if (m_flTemperament <= 0.0f) m_flTemperament = 1.0f; + m_flLastAggression = 0.5f; + m_vWanderGoal = g_vecZero; + m_iWanderTime = 0; + m_vTravelSmooth = g_vecZero; + m_iTravelSampleTime = 0; + m_fTravelTurn = 0.0f; + m_iLastBhopJump = 0; + m_iMidairCrouchUntil = 0; + m_iGrenadeThrowTime = 0; + m_iGrenadeCookTime = 0; + m_iReachCheckTime = 0; + m_bEnemyHardToReach = false; + m_pReachEnemy = NULL; + ResetWaypointVars(); } diff --git a/source/src/bot/ac_bot.h b/source/src/bot/ac_bot.h index a2c8d8c9..f238b6b2 100644 --- a/source/src/bot/ac_bot.h +++ b/source/src/bot/ac_bot.h @@ -26,6 +26,9 @@ public: // AI Functions virtual bool ChoosePreferredWeapon(void); + bool ChooseEliteWeapon(void); // best+ weapon choice + float ScoreWeapon(int iGun, float flDist); + virtual void GetPreferredRange(float &flMinDist, float &flMaxDist); void Reload(int Gun); virtual entity *SearchForEnts(bool bUseWPs, float flRange=9999.0f, float flMaxHeight=JUMP_HEIGHT); diff --git a/source/src/bot/ac_bot_ai.cpp b/source/src/bot/ac_bot_ai.cpp index a17be56a..da42f8ce 100644 --- a/source/src/bot/ac_bot_ai.cpp +++ b/source/src/bot/ac_bot_ai.cpp @@ -28,10 +28,152 @@ weaponinfo_s WeaponInfoTable[MAX_WEAPONS] = { TYPE_AUTO, 0.0f, 50.0f, 3.0f, 50.0f, 0 } // AKIMBO }; +// The best+ (elite) bots don't use the bonus-point scheme above: they weigh +// every weapon on the damage it can actually land at the current distance. +// flCloseRange/flFarRange are where the weapon starts/stop being useful, +// flDps is the raw damage per second and flAccuracy is how much of that lands +// at the ideal range (spread, recoil, travel time). +struct elite_weaponinfo_s +{ + float flCloseRange; + float flIdealRange; + float flFarRange; + float flDps; + float flAccuracy; +}; + +static const elite_weaponinfo_s EliteWeaponInfo[MAX_WEAPONS] = +{ + // ---- close -- ideal -- far ---- dps -- accuracy + { 0.0f, 1.5f, 3.5f, 100.0f, 0.95f }, // KNIFE + { 2.0f, 12.0f, 45.0f, 112.0f, 0.85f }, // PISTOL + { 4.0f, 22.0f, 60.0f, 83.0f, 0.90f }, // CARBINE + { 0.0f, 6.0f, 14.0f, 240.0f, 0.85f }, // SHOTGUN + { 0.0f, 10.0f, 30.0f, 190.0f, 0.80f }, // SUBGUN + { 12.0f, 50.0f, 160.0f, 70.0f, 0.95f }, // SNIPER + { 3.0f, 18.0f, 50.0f, 200.0f, 0.75f }, // ASSAULT + { 8.0f, 15.0f, 25.0f, 200.0f, 0.50f }, // GRENADE + { 0.0f, 8.0f, 25.0f, 200.0f, 0.60f } // AKIMBO +}; + // Code of CACBot - Start +void CACBot::GetPreferredRange(float &flMinDist, float &flMaxDist) +{ + if (!m_pBotSkill->bSmartWeapons) + { + CBot::GetPreferredRange(flMinDist, flMaxDist); + return; + } + + const elite_weaponinfo_s &info = EliteWeaponInfo[m_pMyEnt->gunselect]; + + // The range the bot actually wants to fight at, based on what the weapon + // can do (not on the optimistic table the other skills use) + flMinDist = info.flCloseRange; + flMaxDist = min(info.flFarRange, info.flIdealRange * 2.0f); + + if (flMaxDist < flMinDist + 1.0f) + flMaxDist = flMinDist + 1.0f; +} + +float CACBot::ScoreWeapon(int iGun, float flDist) +{ + const elite_weaponinfo_s &info = EliteWeaponInfo[iGun]; + + // An empty magazine is no weapon at all (the knife is the exception) + if ((iGun != GUN_KNIFE) && !m_pMyEnt->mag[iGun]) + return -1.0f; + + // A grenade is a tool, not a primary weapon: pull it out when the throw + // makes sense, throw it and switch straight back to a gun + if (iGun == GUN_GRENADE) + { + if (!m_pBotSkill->bSmartGrenades || !ShouldThrowGrenade(flDist)) + return -1.0f; + + return 250.0f; + } + + float flRangeFactor; + + // Full damage in the ideal window, a steep drop outside of it + if ((flDist < info.flCloseRange) || (flDist > info.flFarRange)) + flRangeFactor = 0.1f; + else if (flDist < info.flIdealRange) + flRangeFactor = 0.1f + 0.9f * ((flDist - info.flCloseRange) / + (info.flIdealRange - info.flCloseRange)); + else + flRangeFactor = 1.0f - 0.9f * ((flDist - info.flIdealRange) / + (info.flFarRange - info.flIdealRange)); + + float flScore = info.flDps * info.flAccuracy * flRangeFactor; + + // The weapon we already hold has no switch delay + if (iGun == m_pMyEnt->gunselect) + flScore *= 1.15f; + + // Reloading in the middle of a fight is a waste of a magazine + if (m_pMyEnt->weapons[iGun] && m_pMyEnt->weapons[iGun]->reloading) + flScore *= 0.25f; + + // Running dry hurts: save the last rounds for when they matter + if ((iGun != GUN_KNIFE) && (iGun != GUN_GRENADE) && + (m_pMyEnt->ammo[iGun] <= 0) && (m_pMyEnt->mag[iGun] <= 2)) + flScore *= 0.5f; + + // Being able to finish the enemy with a single shot is worth setting up + if (m_pMyEnt->enemy && (m_pMyEnt->enemy->health <= guns[iGun].damage)) + flScore *= 1.3f; + + return flScore; +} + +bool CACBot::ChooseEliteWeapon() +{ + if (!m_pMyEnt->enemy) + return false; + + // Keep the grenade in hand while a throw is being lined up: switching away + // lets go of it in whatever direction the bot happens to be facing + if (m_iGrenadeThrowTime > lastmillis) + return true; + + float flDist = GetDistance(m_pMyEnt->enemy->o); + float flBestScore = ScoreWeapon(m_pMyEnt->gunselect, flDist); + int iBestWeapon = m_pMyEnt->gunselect; + + for (int i=0;igunselect) continue; + if (!m_pMyEnt->weapons[i] || !m_pMyEnt->weapons[i]->selectable()) continue; + + float flScore = ScoreWeapon(i, flDist); + + // Only switch when the other weapon is clearly better: constantly + // swapping guns costs more time than a slightly better range gives + if (flScore > flBestScore * 1.15f) + { + flBestScore = flScore; + iBestWeapon = i; + } + } + + if ((iBestWeapon != m_pMyEnt->gunselect) && (lastmillis > m_iChangeWeaponDelay)) + SelectGun(iBestWeapon); + + return true; +} + bool CACBot::ChoosePreferredWeapon() { + if (m_pBotSkill->bSmartWeapons) + return ChooseEliteWeapon(); + + // Same hold while the pin is out + if (m_iGrenadeThrowTime > lastmillis) + return true; + short bestWeapon = m_pMyEnt->gunselect; short bestWeaponScore = 0; @@ -79,7 +221,13 @@ bool CACBot::ChoosePreferredWeapon() if(i == GUN_GRENADE) { - sWeaponScore += 30; // Nades have high priority + // Nades have high priority, but only when the throw is worth it: + // a grenade chosen just because it scores well is a grenade the + // bot holds while doing nothing else + if (!ShouldThrowGrenade(flDist)) + continue; + + sWeaponScore += 30; } // The ideal distance would be between the Min and Max desired distance. @@ -338,6 +486,12 @@ bool CACBot::HeadToTargetEnt() if (!bToHigh) { AimToVec(o); + + // Elite bots strafe-run to the item and walk around the + // geometry instead of into it + if (m_pBotSkill->bSmartMovement) + SteerTo(o, true); + return true; } } diff --git a/source/src/bot/bot.cpp b/source/src/bot/bot.cpp index 06ff761b..c00f8fbb 100644 --- a/source/src/bot/bot.cpp +++ b/source/src/bot/bot.cpp @@ -84,15 +84,85 @@ void CBot::Spawn() m_vGoal = m_vWaterGoal = g_vecZero; + m_iLastHealth = m_pMyEnt->health; + m_iDodgeTime = 0; + m_iDodgeDir = DIR_NONE; + m_iGrenadeDelay = 0; + m_iStrafeSide = DIR_NONE; + m_iStrafeSideTime = 0; + m_iSteerSide = DIR_NONE; + m_iSteerSideTime = 0; + m_iSteerDir = DIR_NONE; + m_bStrafeRunning = false; + m_iLastDeathSeen = 0; + m_iSwearTime = 0; + if (m_flTemperament <= 0.0f) m_flTemperament = 1.0f; + m_flLastAggression = 0.5f; + m_vWanderGoal = g_vecZero; + m_iWanderTime = 0; + m_vTravelSmooth = g_vecZero; + m_iTravelSampleTime = 0; + m_fTravelTurn = 0.0f; + m_iLastBhopJump = 0; + m_iMidairCrouchUntil = 0; + m_iGrenadeThrowTime = 0; + m_iGrenadeCookTime = 0; + m_iReachCheckTime = 0; + m_bEnemyHardToReach = false; + m_pReachEnemy = NULL; + ResetWaypointVars(); } +static bool PlayerWonMatch(void) +{ + // Did our player just win the game? Same ordering the scoreboard uses: + // flags first in flag modes, then frags, then deaths. + if (!player1 || (player1->team == TEAM_SPECT)) + return false; + + if (!m_teammode) // deathmatch: the player has to be top of the board + { + int iBest = player1->frags; + + loopv(bots) if (bots[i] && (bots[i] != player1) && (bots[i]->frags > iBest)) return false; + loopv(players) if (players[i] && (players[i] != player1) && (players[i]->frags > iBest)) return false; + + return true; + } + + int iScores[4]; + calcteamscores(iScores); + + int iMine = team_base(player1->team), iTheirs = iMine ? 0 : 1; + + if (m_flags_ && (iScores[iMine] != iScores[iTheirs])) + return iScores[iMine] > iScores[iTheirs]; + + return iScores[iMine + 2] > iScores[iTheirs + 2]; +} + void CBot::Think() { if (intermission) return; // Bot is dead? if (m_pMyEnt->state == CS_DEAD) { + // Dying is frustrating: once in a while a bot lets it out in chat. + // lastdeath (instead of the dead state) so it happens once per death, + // not on every frame the corpse is lying there. + if (m_pMyEnt->lastdeath != m_iLastDeathSeen) + { + m_iLastDeathSeen = m_pMyEnt->lastdeath; + m_iSwearTime = lastmillis + RandomLong(600, 1600); // a beat, like a real player + } + + if (m_iSwearTime && (m_iSwearTime <= lastmillis)) + { + m_iSwearTime = 0; + SwearOnDeath(); + } + if(lastmillis-m_pMyEnt->lastdeath<1200) { m_pMyEnt->move = 0; @@ -130,6 +200,50 @@ void CBot::Think() SendBotInfo(); } +void CBot::SwearOnDeath(void) +{ + static const char *szCurses[] = + { + "блядь", + "сука", + "чёрт", + "пиздец", + "блин", + "ёбаный рот", + "твою мать", + "заебало", + "ну и хуйня", + "охренеть", + "блять, ну как так", + "сука, кемпер", + "ну нихуя себе", + "да ёб твою мать" + }; + + // Bots that have spent the whole game in somebody's face take it worst: + // roughly 3.5% for a cautious one, 10% for a madman (mean about 5%) + float flChance = 2.5f + 7.5f * m_flLastAggression; + + // And handing our player the match deserves a little extra + if (intermission && PlayerWonMatch()) + flChance *= 1.5f; + + if (RandomLong(1, 1000) > int(flChance * 10.0f)) + return; + + SayText(szCurses[RandomLong(0, sizeof(szCurses)/sizeof(szCurses[0]) - 1)]); +} + +void CBot::SayText(const char *text) +{ + // Same formatting the client uses for a chat line from another player, so + // it lands in the chat where everybody else's messages do + if (!text || !*text) + return; + + conoutf("%s:\f0 %s", colorname(m_pMyEnt), highlight(text)); +} + void CBot::AimToVec(const vec &o) { m_pMyEnt->targetpitch = atan2(o.z-m_pMyEnt->o.z, GetDistance(o))*180/PI; @@ -283,6 +397,11 @@ float CBot::GetDistance(entity *e) bool CBot::SelectGun(int Gun) { if(!m_pMyEnt->weapons[Gun]->selectable()) return false; + // A grenade with the pin out cannot be put away: the switch would leave the + // live grenade behind at the world origin. ReleaseCookedGrenade() throws it + // as soon as the aim is no longer being kept up. + if(m_pMyEnt->weaponsel && (m_pMyEnt->weaponsel->type == GUN_GRENADE) && + (((grenades *)m_pMyEnt->weaponsel)->state == GST_INHAND)) return false; if(m_pMyEnt->weaponsel->reloading || m_pMyEnt->weaponchanging) return false; m_pMyEnt->gunselect = Gun; if(m_pMyEnt->weaponsel->type != Gun) @@ -359,14 +478,14 @@ void CBot::SetMoveDir(int iMoveDir, bool add) { if (iMoveDir & FORWARD) m_pMyEnt->move = 1; - else if (m_iMoveDir & BACKWARD) + else if (iMoveDir & BACKWARD) m_pMyEnt->move = -1; else if (!add) m_pMyEnt->move = 0; if (iMoveDir & LEFT) m_pMyEnt->strafe = 1; - else if (m_iMoveDir & RIGHT) + else if (iMoveDir & RIGHT) m_pMyEnt->strafe = -1; else if (!add) m_pMyEnt->strafe = 0; diff --git a/source/src/bot/bot.h b/source/src/bot/bot.h index 86813e7d..31ee0d32 100644 --- a/source/src/bot/bot.h +++ b/source/src/bot/bot.h @@ -103,6 +103,12 @@ struct bot_skill_s bool bCanPredict; // Can this bot predict his enemy position? bool bCircleStrafe; // Can this bot circle strafe? bool bCanSearchItemsInCombat; + // The following are only enabled for the elite ("best+") skill + bool bSmartMovement; // Steer around walls and use diagonal movement for speed + bool bSmartWeapons; // Weigh weapons on what they can actually do at this range + bool bSmartGrenades; // Only throw grenades when they are useful (and won't hurt us) + bool bSmartTargeting; // Pick targets on threat instead of distance only + bool bDodgeFire; // React to being shot at }; enum EBotWeaponTypes @@ -148,6 +154,10 @@ public: botent *m_pMyEnt; int m_iLastBotUpdate; + // Rolled once when the bot is created, so it keeps its own streak for the + // whole game instead of drawing a new character every respawn + void RollTemperament(void) { m_flTemperament = RandomFloat(0.6f, 1.4f); } + // Combat variabels playerent *m_pPrevEnemy; int m_iShootDelay; @@ -212,7 +222,35 @@ public: int m_iAimDelay; float m_fYawToTurn, m_fPitchToTurn; bot_skill_s *m_pBotSkill; // Pointer to current bot skill - short m_sSkillNr; // Skill number, 0 == best 4 == worst + short m_sSkillNr; // Skill number, 0 == best+ 5 == worst + + // Elite (best+) behaviour + int m_iLastHealth; // Health at the last think, used to notice getting shot + int m_iDodgeTime; // Dodge until this time + int m_iDodgeDir; // Direction to dodge in + int m_iGrenadeDelay; // Don't spam grenades + int m_iStrafeSide; // Side the bot is currently circling to (LEFT or RIGHT) + int m_iStrafeSideTime; // Time to reconsider the circling side + int m_iSteerSide; // Side the bot strafe-runs on (LEFT or RIGHT) + int m_iSteerSideTime; // Time to reconsider the strafe-run side + int m_iSteerDir; // Movement keys the steering picked last frame + bool m_bStrafeRunning; // Sprinting with two keys at the moment + vec m_vWanderGoal; // Where to head when there is no waypoint to follow + int m_iWanderTime; // When to pick a new wander goal + vec m_vTravelSmooth; // Travel direction a moment ago (how straight are we going) + int m_iTravelSampleTime; // When to take the next straightness sample + float m_fTravelTurn; // Degrees turned in the last sample window + int m_iLastBhopJump; // Last hop, so the mid-air crouch knows how long we are up + int m_iMidairCrouchUntil; // Hold the show-off tuck until this (never past the landing) + int m_iGrenadeThrowTime; // Lining up a throw: stand still until this + int m_iGrenadeCookTime; // When the pin came out (0 = nothing in hand) + int m_iReachCheckTime; // When to look again at whether the enemy is reachable + bool m_bEnemyHardToReach; // Result of that check + playerent *m_pReachEnemy; // Which enemy the check was about + int m_iLastDeathSeen; // lastdeath of the last death we reacted to + int m_iSwearTime; // When the bot lets out its death curse (0 = nothing pending) + float m_flTemperament; // Personal streak: some bots push, some play it safe + float m_flLastAggression; // Aggression on the last living frame void AimToVec(const vec &o); void AimToIdeal(void); @@ -267,6 +305,28 @@ public: bool InUnreachableList(entity *e); virtual bool DoSPStuff(void) = 0; vec GetEnemyPos(playerent *d); + // Elite (best+) behaviour + void MoveDirToVec(int iDir, vec &vDir); + float GetMoveClearance(const vec &vDir, float flDist); + int SteerTo(const vec &vGoal, bool bStrafeRun = false, bool bAllowBackward = true); + vec GetNadeRepulsion(void); + void BhopAlongLine(bool bStraight, float flGoalDist); + vec GetClearDirection(const vec &vWant, float flDist); + playerent *GetGrenadeThrower(void); + bool EnemyIsHardToReach(playerent *pEnemy); + bool IsCookingGrenadeAtMe(playerent *d); + float GetAggression(void); + void SwearOnDeath(void); + int CountEnemiesNear(float flRadius); + void SayText(const char *text); + void DoSmartCombatNav(void); + bool ShouldThrowGrenade(float flDist, playerent *pAimAt = NULL); + bool ThrowGrenade(playerent *pTarget); + void ReleaseCookedGrenade(void); + float GetGrenadeLobPitch(float flDist, float flHeight, float &flFlight); + void AvoidGrenades(void); + int GetEnemyScore(playerent *d, float flDist); + virtual void GetPreferredRange(float &flMinDist, float &flMaxDist); bool AStar(void); float AStarCost(waypoint_s *pWP1, waypoint_s *pWP2); void CleanAStarLists(bool bPathFailed); @@ -330,10 +390,10 @@ class CBotManager bool m_bInit; bool m_bBotsShoot; bool m_bIdleBots; - bot_skill_s m_BotSkills[5]; // 5 different bot skills, 0 = best 4 = worst + bot_skill_s m_BotSkills[6]; // 6 different bot skills, 0 = best+ 5 = worst int m_iFrameTime; int m_iPrevTime; - short m_sBotSkill; // Bad - Worse - Medium - Good - Best + short m_sBotSkill; // Bad - Worse - Medium - Good - Best - Best+ short m_sMaxAStarBots; // Max bots that can use a* at the same time short m_sUsingAStarBotsCount; // Number of bots that are using a* short m_sCurrentTriggerNr; // Current waypoint trigger bots should use diff --git a/source/src/bot/bot_ai.cpp b/source/src/bot/bot_ai.cpp index 0d2c76a9..10c3a7f2 100644 --- a/source/src/bot/bot_ai.cpp +++ b/source/src/bot/bot_ai.cpp @@ -23,8 +23,21 @@ vec CBot::GetEnemyPos(playerent *d) if (m_pBotSkill->bCanPredict) { - // How higher the skill, how 'more' the bot predicts - float flPredictTime = RandomFloat(0.8f, 1.2f) / (m_sSkillNr+1); + float flPredictTime; + + if (m_sSkillNr == 0) + { + // The elite bots aim and turn nearly instantly, so they only have to + // compensate for a single frame. Leading the target by a second - the + // old behaviour - makes them miss enemies that strafe. + flPredictTime = RandomFloat(0.15f, 0.35f); + } + else + { + // How higher the skill, how 'more' the bot predicts + flPredictTime = RandomFloat(0.8f, 1.2f) / max((int)m_sSkillNr, 1); + } + o = PredictPos(o, d->vel, flPredictTime); } @@ -38,30 +51,32 @@ vec CBot::GetEnemyPos(playerent *d) switch (m_sSkillNr) { case 0: - // no offset + // BEST+, aim dead on + case 1: + // BEST, no offset offset.x = 0; offset.y = 0; offset.z = 0; break; - case 1: + case 2: // GOOD, offset a little for x, y, and z offset.x = RandomFloat(-3, 3) * flScale; offset.y = RandomFloat(-3, 3) * flScale; offset.z = RandomFloat(-6, 6) * flScale; break; - case 2: - // FAIR, offset somewhat for x, y, and z + case 3: + // MEDIUM, offset somewhat for x, y, and z offset.x = RandomFloat(-8, 8) * flScale; offset.y = RandomFloat(-8, 8) * flScale; offset.z = RandomFloat(-12, 12) * flScale; break; - case 3: - // POOR, offset for x, y, and z + case 4: + // WORSE, offset for x, y, and z offset.x = RandomFloat(-15, 15) * flScale; offset.y = RandomFloat(-15, 15) * flScale; offset.z = RandomFloat(-25, 25) * flScale; break; - case 4: + default: // BAD, offset lots for x, y, and z offset.x = RandomFloat(-20, 20) * flScale; offset.y = RandomFloat(-20, 20) * flScale; @@ -142,12 +157,18 @@ bool CBot::FindEnemy(void) continue; flDist = GetDistance(d->o); - EnemyVal = 1; - if (flDist < flNearestDist) + if (m_pBotSkill->bSmartTargeting) + EnemyVal = GetEnemyScore(d, flDist); + else { - EnemyVal+=2; - flNearestDist = flDist; + EnemyVal = 1; + + if (flDist < flNearestDist) + { + EnemyVal+=2; + flNearestDist = flDist; + } } if (EnemyVal > BestEnemyVal) @@ -165,12 +186,18 @@ bool CBot::FindEnemy(void) if(DetectEnemy(player1)) { flDist = GetDistance(player1->o); - EnemyVal = 1; - if (flDist < flNearestDist) + if (m_pBotSkill->bSmartTargeting) + EnemyVal = GetEnemyScore(player1, flDist); + else { - EnemyVal+=2; - flNearestDist = flDist; + EnemyVal = 1; + + if (flDist < flNearestDist) + { + EnemyVal+=2; + flNearestDist = flDist; + } } if (EnemyVal > BestEnemyVal) @@ -505,17 +532,6 @@ bool CBot::HuntEnemy(void) else condebug("New hunt pos"); - // Aim to position - //AimToVec(m_vHuntLocation); - - int iMoveDir = GetDirection(GetViewAngles(), m_pMyEnt->o, m_vHuntLocation); - - if (iMoveDir != DIR_NONE) - { - m_iMoveDir = iMoveDir; - m_iCombatNavTime = lastmillis + 125; - } - bool aimtopos = true; if ((lastmillis - m_iSawEnemyTime) > 1500) @@ -547,6 +563,47 @@ bool CBot::HuntEnemy(void) if (aimtopos) AimToVec(m_vHuntLocation); + // Can't get to them, or to where they are shooting from? A grenade over the + // top is faster than walking into whatever they are hiding behind - but + // only once they have been out of sight long enough to be camping there + if (m_pBotSkill->bSmartGrenades && m_pHuntTarget && + m_pMyEnt->mag[GUN_GRENADE] && (m_iGrenadeDelay < lastmillis) && + ((lastmillis - m_iSawEnemyTime) > 1500)) + { + float flTargetDist = GetDistance(m_pHuntTarget->o); + + if ((flTargetDist > 7.0f) && (flTargetDist < 42.0f) && + EnemyIsHardToReach(m_pHuntTarget)) + { + if (m_pMyEnt->weaponsel->type != GUN_GRENADE) + SelectGun(GUN_GRENADE); + else + ThrowGrenade(m_pHuntTarget); + } + } + + // Movement comes after the aim: strafe running turns the view 45 degrees + // off the travel direction, so it has to be the last word on where the bot + // looks, or the offset is overwritten and the bot jogs instead of sprinting + if (m_pBotSkill->bSmartMovement) + { + // Elite bots steer around obstacles and sprint diagonally to the target + if (m_iDodgeTime > lastmillis) + SetMoveDir(m_iDodgeDir, false); + else + SteerTo(m_vHuntLocation, true); + } + else + { + int iMoveDir = GetDirection(GetViewAngles(), m_pMyEnt->o, m_vHuntLocation); + + if (iMoveDir != DIR_NONE) + { + m_iMoveDir = iMoveDir; + m_iCombatNavTime = lastmillis + 125; + } + } + debugbeam(m_pMyEnt->o, m_vHuntLocation); if (m_fYawToTurn <= 25.0f) @@ -558,7 +615,9 @@ bool CBot::HuntEnemy(void) m_iHuntPauseTime = lastmillis + 200; } - if (m_iHuntPauseTime >= lastmillis) + // Elite bots have no reason to stop: their aim is good enough to turn while + // running and the steering takes care of the walls + if (!m_pBotSkill->bSmartMovement && (m_iHuntPauseTime >= lastmillis)) { m_pMyEnt->move = 0; m_fPrevHuntDist = 0.0f; @@ -628,6 +687,17 @@ void CBot::CheckWeaponSwitch() void CBot::ShootEnemy() { if(!m_pMyEnt->enemy) return; + + // Elite bots think before pulling the pin: the blast is 10 cubes wide and + // the fuse is only two seconds. A lob does not need to see the target at + // all - it is aimed over whatever is in the way - which is why the grenade + // is handled before the visibility check. + if (m_pBotSkill->bSmartGrenades && (m_pMyEnt->gunselect == GUN_GRENADE)) + { + ThrowGrenade(m_pMyEnt->enemy); + return; // a grenade in hand: the gun is not the answer this frame + } + if(!IsVisible(m_pMyEnt->enemy)) return; m_iSawEnemyTime = lastmillis; @@ -642,12 +712,12 @@ void CBot::ShootEnemy() { if (m_pMyEnt->mag[m_pMyEnt->gunselect]) { + float yawtoturn = fabs(WrapYZAngle(m_pMyEnt->yaw - m_pMyEnt->targetyaw)); + float pitchtoturn = fabs(WrapYZAngle(m_pMyEnt->pitch - m_pMyEnt->targetpitch)); + // If the bot is using a sniper only shoot if crosshair is near the enemy if (WeaponInfoTable[m_pMyEnt->gunselect].eWeaponType == TYPE_SNIPER) { - float yawtoturn = fabs(WrapYZAngle(m_pMyEnt->yaw - m_pMyEnt->targetyaw)); - float pitchtoturn = fabs(WrapYZAngle(m_pMyEnt->pitch - m_pMyEnt->targetpitch)); - if ((yawtoturn > 5) || (pitchtoturn > 15)) // UNDONE: Should be skill based return; } @@ -739,16 +809,46 @@ void CBot::CheckScope() void CBot::MainAI() { + // How hard this bot feels like fighting right now (temperament, health, + // ammo, how many enemies are around). Slowly decaying peak, so a bot that + // was pushing a moment ago still counts as a pusher when it gets killed + // instead of instantly reading as "nearly dead therefore timid". + m_flLastAggression = max(GetAggression(), m_flLastAggression - 0.03f); + + // A grenade with the pin out must not be carried around: if the throw is no + // longer being lined up, get rid of it before anything can switch weapons + ReleaseCookedGrenade(); + // Default bots will run forward m_pMyEnt->move = 1; // Default bots won't strafe m_pMyEnt->strafe = 0; - // Whatever the bot is doing, check for needed crouch - if(CheckCrouch()) m_pMyEnt->trycrouch = true; + // Whatever the bot is doing, check for needed crouch. The mid-air tuck is + // kept for a moment on purpose (BhopAlongLine sets the deadline), so that it + // visibly lasts instead of being cancelled again by this line next frame. + if(CheckCrouch() || (m_iMidairCrouchUntil > lastmillis)) m_pMyEnt->trycrouch = true; else m_pMyEnt->trycrouch = false; + // Notice getting shot. Elite bots don't keep walking in a straight line + // while someone empties a magazine into their back. + if (m_pBotSkill->bDodgeFire) + { + if ((m_pMyEnt->health < m_iLastHealth) && (m_iDodgeTime < lastmillis)) + { + // Hurt bots care a lot more about not being hit again, healthy ones + // shrug it off and keep pushing + float flCaution = 1.0f - m_flLastAggression; + + m_iDodgeTime = lastmillis + RandomLong(250, 450) + int(flCaution * 350.0f); + m_iDodgeDir = RandomLong(0, 1) ? LEFT : RIGHT; + if (RandomLong(1, 100) <= int(20 + flCaution * 40)) + m_pMyEnt->jumpnext = true; + } + } + m_iLastHealth = m_pMyEnt->health; + if (!BotManager.BotsShoot() && m_pMyEnt->enemy) m_pMyEnt->enemy = NULL; // Clear enemy when bots may not shoot @@ -877,12 +977,50 @@ void CBot::MainAI() } } - // Check if the bot has to strafe - CheckStrafe(); + if (m_pBotSkill->bSmartMovement) + { + // No waypoint to follow (or none in reach): pick a spot with + // room ahead and walk there properly. Blindly holding the + // forward key is what makes bots jog into walls in circles. + if ((m_vWanderGoal == g_vecZero) || (m_iWanderTime < lastmillis) || + (GetDistance(m_vWanderGoal) < 3.0f)) + { + float flBestClear = -1.0f; + vec vBestDir(1.0f, 0.0f, 0.0f); - m_pMyEnt->move = 1; + // Probe 8 directions and take the one with the most room + for (int i=0;i<8;i++) + { + vec vDir(cosf(i*PI/4.0f), sinf(i*PI/4.0f), 0.0f); + float flClear = GetMoveClearance(vDir, 25.0f); + + if (flClear > flBestClear) + { + flBestClear = flClear; + vBestDir = vDir; + } + } + + m_vWanderGoal = m_pMyEnt->o; + m_vWanderGoal.add(vec(vBestDir).mul(max(flBestClear, 12.0f))); + m_vWanderGoal.z = m_pMyEnt->o.z; + m_iWanderTime = lastmillis + RandomLong(4000, 8000); + } + + AimToVec(m_vWanderGoal); + SteerTo(m_vWanderGoal, true); + } + else + { + // Check if the bot has to strafe + CheckStrafe(); + + m_pMyEnt->move = 1; + } } - } + } // A live grenade beats every other plan: get out of the blast radius + if (m_pBotSkill->bSmartGrenades) + AvoidGrenades(); } void CBot::DoCombatNav() @@ -890,7 +1028,7 @@ void CBot::DoCombatNav() if (m_iCombatNavTime > lastmillis) { // If bot has a lower skill and has to turn much, wait - if ((m_sSkillNr > 2) && (m_fYawToTurn > 90.0f)) + if ((m_sSkillNr > 3) && (m_fYawToTurn > 90.0f)) { ResetMoveSpeed(); } @@ -964,6 +1102,10 @@ void CBot::DoCombatNav() bSearchItems = (m_pMyEnt->ammo[m_pMyEnt->gunselect] <= WeaponInfoTable[m_pMyEnt->gunselect].sMinDesiredAmmo); + // Hurt bots go looking for health and armour instead of trading + if (m_pBotSkill->bSmartMovement && (m_pMyEnt->health < 40)) + bSearchItems = true; + if (bSearchItems) m_pTargetEnt = SearchForEnts(false, 25.0f, 1.0f); } @@ -1020,7 +1162,7 @@ void CBot::DoCombatNav() } // High skill and enemy is close? - if ((m_sSkillNr <= 1) && (m_fYawToTurn < 80.0f) && (flDist <= 20.0f) && + if ((m_sSkillNr <= 2) && (m_fYawToTurn < 80.0f) && (flDist <= 20.0f) && (m_iCombatJumpDelay < lastmillis)) { // Randomly jump a bit, to avoid some basic firepower ;) @@ -1090,6 +1232,14 @@ void CBot::DoCombatNav() } } + // Elite bots keep steering while they fight: walk around obstacles, move + // diagonally when that is faster and keep their weapon's preferred range + if (m_pBotSkill->bSmartMovement) + { + DoSmartCombatNav(); + return; + } + if (WeaponInfoTable[m_pMyEnt->gunselect].eWeaponType == TYPE_MELEE) return; // Simply walk towards enemy if using a melee type @@ -2013,4 +2163,1043 @@ bool CBot::IsInFOV(const vec &o) return m_pBotSkill->iFov/2.0f >= flAngle/RAD; } + +// ================================================================== +// Elite (best+) behaviour +// ================================================================== + +void CBot::MoveDirToVec(int iDir, vec &vDir) +{ + // Same math moveplayer() uses to turn the movement keys into a world + // direction. The result is deliberately not normalized: pressing two keys + // at once really moves ~1.4x faster in AssaultCube, so a diagonal is a + // speed boost, not a rounding error. + float flMove = (iDir & FORWARD) ? 1.0f : ((iDir & BACKWARD) ? -1.0f : 0.0f); + float flStrafe = (iDir & LEFT) ? 1.0f : ((iDir & RIGHT) ? -1.0f : 0.0f); + float flYaw = m_pMyEnt->yaw; + + vDir = g_vecZero; + vDir.x = flMove*cosf(RAD*(flYaw-90.0f)) + flStrafe*cosf(RAD*(flYaw-180.0f)); + vDir.y = flMove*sinf(RAD*(flYaw-90.0f)) + flStrafe*sinf(RAD*(flYaw-180.0f)); +} + +float CBot::GetMoveClearance(const vec &vDir, float flDist) +{ + traceresult_s tr; + vec from = m_pMyEnt->o, to; + float flClear = flDist; + + // One trace at eye height and one just above the floor: the low trace is + // what catches the low walls and ledges the old bots kept walking into + for (int i=0;i<2;i++) + { + if (i == 1) + from.z -= (m_pMyEnt->eyeheight - 1.0f); + + to = from; + to.x += vDir.x*flDist; + to.y += vDir.y*flDist; + + TraceLine(from, to, m_pMyEnt, false, &tr); + + if (tr.collided) + flClear = min(flClear, GetDistance(from, tr.end)); + } + + return flClear; +} + +static float YawFromDir(const vec &vDir) +{ + // Same conversion AimToVec() uses + return -(float)atan2(vDir.x, vDir.y)/PI*180.0f + 180.0f; +} + +void CBot::BhopAlongLine(bool bStraight, float flGoalDist) +{ + // Hopping along a straight line is much harder to hit than gliding along + // it, and with two movement keys held the double jump makes it faster too. + // On a curve the bot stays on the floor: friction is the only thing that + // lets it change direction. + if (!bStraight || (flGoalDist < 8.0f) || + (m_pMyEnt->vel.magnitudexy() < 0.6f)) + return; + + if (m_pMyEnt->onfloor) + { + m_pMyEnt->jumpnext = true; + m_iLastBhopJump = lastmillis; + } + else + { + int iAirTime = lastmillis - m_iLastBhopJump; + + if (m_iMidairCrouchUntil && (lastmillis > m_iMidairCrouchUntil)) + m_iMidairCrouchUntil = 0; + + if ((m_flLastAggression > 0.6f) && (iAirTime > 0) && (iAirTime < 250)) + { + // Style points: the pushy ones tuck their knees in on the way up + // and have them straight again long before they land, so the + // crouch never costs them the landing. The deadline is what makes + // it last: without it the next frame's crouch check cancels it. + m_iMidairCrouchUntil = m_iLastBhopJump + 300; + m_pMyEnt->trycrouch = true; + } + } +} + +vec CBot::GetNadeRepulsion(void) +{ + // Every live grenade within blast range pushes the path sideways, so the + // bot walks around a nade field instead of straight through it. Without + // this a bot heading for a waypoint happily walks past a grenade that is + // about to blow up in its face. + vec vRep = g_vecZero; + + if (!m_pBotSkill->bSmartGrenades) + return vRep; + + loopv(bounceents) + { + bounceent *b = bounceents[i]; + + // Our own grenade is not a threat to run from (it is in flight on its + // way to somebody else), and treating it as one makes bots bolt away + // from every throw they make + if (!b || (b->bouncetype != BT_NADE) || !b->isalive(lastmillis) || + (b->owner == m_pMyEnt)) + continue; + + vec vAway = m_pMyEnt->o; + vAway.sub(b->o); + vAway.z = 0.0f; + + float flDist = vAway.magnitude(); + + if (flDist > EXPDAMRAD*1.4f) + continue; + + if (flDist < 0.2f) + vAway = vec(1.0f, 0.0f, 0.0f); + else + vAway.div(flDist); + + // The closer it is, the harder the push + float flWeight = (EXPDAMRAD*1.4f - flDist) / (EXPDAMRAD*1.4f); + vRep.add(vec(vAway).mul(flWeight*flWeight*6.0f)); + } + + return vRep; +} + +int CBot::CountEnemiesNear(float flRadius) +{ + // How many living enemies are close enough to matter. Used to decide + // whether charging in is a good idea. + int iCount = 0; + + loopv(bots) + { + playerent *d = bots[i]; + + if (!d || (d == m_pMyEnt) || (d->state != CS_ALIVE)) + continue; + + if (!isteam(d->team, m_pMyEnt->team) && (GetDistance(d->o) <= flRadius)) + iCount++; + } + + if (player1 && (player1 != m_pMyEnt) && (player1->state == CS_ALIVE) && + !isteam(player1->team, m_pMyEnt->team) && (GetDistance(player1->o) <= flRadius)) + iCount++; + + return iCount; +} + +float CBot::GetAggression(void) +{ + // How badly this bot wants to be in somebody's face right now. Health, + // armour and ammo give the fighting spirit, being hurt, dry, reloading or + // outnumbered takes it away again. + float flAggression = m_flTemperament; // this bot's own streak: pusher or runner + + flAggression *= 0.35f + 0.65f * (clamp(m_pMyEnt->health, 0, 100) / 100.0f); + flAggression *= 0.75f + 0.25f * (clamp(m_pMyEnt->armour, 0, 100) / 100.0f); + + int iGun = m_pMyEnt->gunselect; + + if (iGun != GUN_KNIFE) + { + // Full aggression with a healthy amount of ammo, very little while + // running on the last few rounds + float flHave = m_pMyEnt->mag[iGun] + m_pMyEnt->ammo[iGun]; + float flWant = max(WeaponInfoTable[iGun].sMinDesiredAmmo * 2.5f, 1.0f); + float flAmmo = clamp(flHave / flWant, 0.0f, 1.0f); + + flAggression *= 0.35f + 0.65f * flAmmo; + + if (!m_pMyEnt->mag[iGun]) + flAggression *= 0.6f; // nothing loaded: time to get out and reload + } + + if (m_pMyEnt->weaponsel && m_pMyEnt->weaponsel->reloading) + flAggression *= 0.75f; + + if (m_pMyEnt->enemy && (m_pMyEnt->enemy->health <= 40)) + flAggression *= 1.25f; // a nearly dead enemy is worth running at + + int iEnemies = CountEnemiesNear(30.0f); + + if (iEnemies > 2) + flAggression *= 0.7f; + else if (iEnemies == 2) + flAggression *= 0.85f; + + return clamp(flAggression, 0.15f, 1.0f); +} + +bool CBot::IsCookingGrenadeAtMe(playerent *d) +{ + if (!d || (d == m_pMyEnt) || (d->state != CS_ALIVE) || + isteam(d->team, m_pMyEnt->team)) + return false; + + weapon *pWeap = d->weapons[GUN_GRENADE]; + + if (!pWeap) + return false; + + grenades *pNade = (grenades *)pWeap; + + // No grenades left and none in hand: nothing is going to explode + if (!d->mag[GUN_GRENADE] && !pNade->inhandnade && (pNade->state == GST_NONE)) + return false; + + // In hand (pulled, not thrown yet), being thrown, or simply selected and + // aimed at us: any of those ends with a grenade landing on our head + if (!pNade->inhandnade && (pNade->state != GST_INHAND) && + (pNade->state != GST_THROWING) && (d->weaponsel != pWeap)) + return false; + + float flDist = GetDistance(d->o); + + if (flDist > 40.0f) + return false; + + if (!IsVisible(d->o)) + return false; + + // Facing us? A grenade aimed the other way is somebody else's problem + vec vToMe = m_pMyEnt->o; + vToMe.sub(d->o); + vToMe.z = 0.0f; + + if (vToMe.magnitude() < 0.1f) + return true; + + // Same forward vector moveplayer() builds from the yaw + vec vView(sinf(RAD*d->yaw), -cosf(RAD*d->yaw), 0.0f); + + vToMe.normalize(); + + return (vView.dot(vToMe) > 0.6f) || (pNade->state == GST_THROWING); +} + +playerent *CBot::GetGrenadeThrower(void) +{ + // Whoever is about to lob something explosive at us right now + playerent *pThrower = NULL; + float flNearest = 9999.0f; + + loopv(bots) + { + playerent *d = bots[i]; + + if (!IsCookingGrenadeAtMe(d)) + continue; + + float flDist = GetDistance(d->o); + + if (flDist < flNearest) + { + flNearest = flDist; + pThrower = d; + } + } + + if (IsCookingGrenadeAtMe(player1)) + { + float flDist = GetDistance(player1->o); + + if (flDist < flNearest) + pThrower = player1; + } + + return pThrower; +} + +vec CBot::GetClearDirection(const vec &vWant, float flDist) +{ + // The wanted direction, or the closest one to it that is actually free. + // This is what lets a sprinting bot follow a corridor instead of dropping + // out of the sprint at every corner: the legs keep the speed, only the + // direction bends. + static const float flAngles[] = { 0.0f, 30.0f, -30.0f, 60.0f, -60.0f, 90.0f, -90.0f }; + + vec vBest = vWant; + float flBestClear = -9999.0f; + + for (int i=0;i<7;i++) + { + float flCos = cosf(RAD*flAngles[i]), flSin = sinf(RAD*flAngles[i]); + vec vTry(vWant.x*flCos - vWant.y*flSin, vWant.x*flSin + vWant.y*flCos, 0.0f); + float flClear = GetMoveClearance(vTry, flDist); + + // Prefer going straight when the room is about the same + flClear -= fabs(flAngles[i]) * 0.004f; + + if (flClear > flBestClear) + { + flBestClear = flClear; + vBest = vTry; + } + } + + return vBest; +} + +int CBot::SteerTo(const vec &vGoal, bool bStrafeRun, bool bAllowBackward) +{ + // Lobbing a grenade: stand still and let the aim settle. The throw uses the + // direction the bot is actually looking in, and the sprint keeps turning + // the view 45 degrees off the target. + if (m_iGrenadeThrowTime > lastmillis) + { + ResetMoveSpeed(); + m_bStrafeRunning = false; + m_iSteerDir = DIR_NONE; + return DIR_NONE; + } + + // Try every combination of the movement keys and pick the one that closes + // the distance to the goal fastest without walking into something. This is + // what makes the bot hug corners instead of bumping into them. + static const int iDirs[] = + { + FORWARD, + FORWARD | LEFT, + FORWARD | RIGHT, + LEFT, + RIGHT, + BACKWARD, + BACKWARD | LEFT, + BACKWARD | RIGHT + }; + + vec vToGoal = vGoal; + vToGoal.sub(m_pMyEnt->o); + vToGoal.z = 0.0f; + + float flGoalDist = vToGoal.magnitude(); + + if (flGoalDist < 0.25f) + { + ResetMoveSpeed(); + m_iSteerDir = DIR_NONE; + return DIR_NONE; + } + + vToGoal.div(flGoalDist); + + // Where we would actually like to travel: towards the goal, pushed aside + // by any grenade close enough to hurt us + vec vSteer = vToGoal; + vSteer.add(GetNadeRepulsion()); + + if (vSteer.magnitude() < 0.1f) + vSteer = vToGoal; // the pushes cancelled out: ignore them + else + vSteer.normalize(); + + const float flTraceDist = min(flGoalDist, 3.0f); + int iBestDir = DIR_NONE; + float flBestScore = -9999.0f; + int iDirCount = bAllowBackward ? 8 : 5; + + // How straight are we going? Sampled a few times a second: a steady line + // means the bot can hop along it, a curve means it stays on the floor + // where it can actually turn + if (m_iTravelSampleTime < lastmillis) + { + float flTurn = 0.0f; + + if (m_vTravelSmooth.magnitudexy() > 0.1f) + { + flTurn = fabs(YawFromDir(vSteer) - YawFromDir(m_vTravelSmooth)); + + if (flTurn > 180.0f) + flTurn = 360.0f - flTurn; + } + + m_fTravelTurn = flTurn; + m_vTravelSmooth = vSteer; + m_iTravelSampleTime = lastmillis + 250; + } + + // Strafe running: forward + a strafe key moves ~1.4x faster than one key. + // The catch is that the bot then travels 45 degrees off where it looks, so + // the view is turned 45 degrees the other way and the diagonal points + // exactly at the goal. This is how players cross a map quickly. + bool bRun = bStrafeRun && (flGoalDist > 3.0f) && + (m_pMyEnt->onfloor || m_bStrafeRunning) && + !UnderWater(m_pMyEnt->o); + + if (bRun) + { + // Bend the sprint around whatever is in the way rather than dropping + // out of it: entering and leaving the sprint every other frame swings + // the view 45 degrees back and forth and the bot never gets up to + // speed + vec vRun = GetClearDirection(vSteer, min(flGoalDist, 3.0f)); + float flNeed = (m_bStrafeRunning ? 0.9f : 1.5f); + + if (flGoalDist < flNeed) + flNeed = flGoalDist; + + if ((GetMoveClearance(vRun, flNeed) + 0.05f) < flNeed) + bRun = false; + else + vSteer = vRun; + } + + m_bStrafeRunning = bRun; + + if (bRun) + { + if ((m_iSteerSide != LEFT) && (m_iSteerSide != RIGHT)) + m_iSteerSide = RandomLong(0, 1) ? LEFT : RIGHT; + + if (m_iSteerSideTime < lastmillis) + { + m_iSteerSideTime = lastmillis + RandomLong(2000, 4000); + + // Look towards the side with room, not into a wall: the body + // travels along vSteer either way, but a bot that can see where + // it is going shoots first + vec vLeft(vSteer.y, -vSteer.x, 0.0f); + vec vRight(-vSteer.y, vSteer.x, 0.0f); + float flClearLeft = GetMoveClearance(vLeft, 2.0f); + float flClearRight = GetMoveClearance(vRight, 2.0f); + + if (flClearLeft > flClearRight + 0.5f) + m_iSteerSide = LEFT; + else if (flClearRight > flClearLeft + 0.5f) + m_iSteerSide = RIGHT; + } + + m_pMyEnt->targetyaw = WrapYZAngle(YawFromDir(vSteer) + + ((m_iSteerSide == LEFT) ? 45.0f : -45.0f)); + + SetMoveDir(FORWARD | m_iSteerSide, false); + m_iSteerDir = FORWARD | m_iSteerSide; + + // Going straighter than ~10 degrees? Bunny hop: two keys plus a jump + // gives AssaultCube's double jump boost and a hopping bot is a much + // harder target than one gliding along in a line. Curving: stay on + // the floor, friction is what lets it change direction + BhopAlongLine(m_fTravelTurn <= 10.0f, flGoalDist); + + return m_iSteerDir; + } + + for (int i=0;i= 0.25f))) + break; + + int iDir = iDirs[i]; + vec vDir; + + MoveDirToVec(iDir, vDir); + + float flSpeedMul = vDir.magnitude(); + if (flSpeedMul < 0.1f) + continue; // No movement at all + + vDir.div(flSpeedMul); + + // Backpedalling is slower, so prefer turning around over walking back + if (iDir & BACKWARD) + flSpeedMul *= 0.7f; + + // How much of our (extra) speed actually goes towards the goal + float flScore = vDir.dot(vSteer) * flSpeedMul; + + // Walking into a wall is not going towards the goal + float flClear = GetMoveClearance(vDir, flTraceDist); + if (flClear < flTraceDist) + flScore -= (1.0f - (flClear / flTraceDist)) * 3.0f; + + // Stick to last frame's choice unless it is clearly worse: a bot that + // re-picks between two equally good directions every frame looks like + // it is running about for no reason + if ((iDir == m_iSteerDir) && (iDir != DIR_NONE)) + flScore += 0.08f; + + if (flScore > flBestScore) + { + flBestScore = flScore; + iBestDir = iDir; + } + } + + if (iBestDir == DIR_NONE) + { + ResetMoveSpeed(); + m_iSteerDir = DIR_NONE; + return DIR_NONE; + } + + SetMoveDir(iBestDir, false); + m_iSteerDir = iBestDir; + + // A straight line is no reason to be an easy target just because the bot + // is not sprinting: hop along it as well + vec vChosen; + MoveDirToVec(iBestDir, vChosen); + + float flChosenDot = 0.0f; + if (vChosen.magnitude() > 0.1f) + flChosenDot = vChosen.normalize().dot(vSteer); + + BhopAlongLine((m_fTravelTurn <= 10.0f) && (flChosenDot > 0.9848f), flGoalDist); + + return iBestDir; +} + +void CBot::DoSmartCombatNav() +{ + if (!m_pMyEnt->enemy) + return; + + playerent *pEnemy = m_pMyEnt->enemy; + float flDist = Get2DDistance(pEnemy->o); + float flMinDist, flMaxDist; + const float flAggression = GetAggression(); + const float flCaution = 1.0f - flAggression; + + GetPreferredRange(flMinDist, flMaxDist); + + // A hurt or nearly dry bot does not want to be in your face: it widens the + // fight to arm's length so it can reload and use whatever ammo it has left + flMinDist += flCaution * 9.0f; + flMaxDist += flCaution * 14.0f; + + // Direction from us to the enemy, used for every decision here + vec vToEnemy = pEnemy->o; + vToEnemy.sub(m_pMyEnt->o); + vToEnemy.z = 0.0f; + if (vToEnemy.magnitude() < 0.1f) + vToEnemy = vec(1.0f, 0.0f, 0.0f); + vToEnemy.normalize(); + + // The side we move to, towards the side with more room. Keeping the same + // side for a while stops the bot from zig-zagging in place. + if ((m_iStrafeSide != LEFT) && (m_iStrafeSide != RIGHT)) + m_iStrafeSide = RandomLong(0, 1) ? LEFT : RIGHT; + + if (m_iStrafeSideTime < lastmillis) + { + m_iStrafeSideTime = lastmillis + RandomLong(750, 1500); + + vec vLeft(vToEnemy.y, -vToEnemy.x, 0.0f); + vec vRight(-vToEnemy.y, vToEnemy.x, 0.0f); + float flClearLeft = GetMoveClearance(vLeft, 4.0f); + float flClearRight = GetMoveClearance(vRight, 4.0f); + + if (flClearLeft > flClearRight + 0.5f) + m_iStrafeSide = LEFT; + else if (flClearRight > flClearLeft + 0.5f) + m_iStrafeSide = RIGHT; + } + + vec vPerp(vToEnemy.y, -vToEnemy.x, 0.0f); + if (m_iStrafeSide == RIGHT) + vPerp.mul(-1.0f); + + vec vGoal = m_pMyEnt->o; + + if (m_iDodgeTime > lastmillis) + { + // Being shot at: get out of the line of fire + if (m_iDodgeDir == RIGHT) + vPerp.mul(-1.0f); + + vPerp.mul(7.0f); + vGoal.add(vPerp); + } + else if (m_pMyEnt->weaponsel->reloading && + (flDist < (12.0f + flCaution * 22.0f))) + { + // Reloading in the open gets you killed: break off, sideways. A bot + // that has already been hurt breaks off much earlier, one that is + // still healthy keeps pushing until the enemy is close + vec vAway = vToEnemy; + vAway.mul(-(6.0f + flCaution * 8.0f)); + vGoal.add(vAway); + + vPerp.mul(4.0f); + vGoal.add(vPerp); + } + else if ((flCaution > 0.5f) && + ((m_pMyEnt->health < 45) || !m_pMyEnt->mag[m_pMyEnt->gunselect]) && + (flDist < 26.0f)) + { + // Hurt, low on ammo or outnumbered: it is not a duel worth dying in. + // Get out of the fight, let the magazine finish reloading and come + // back with something to shoot with. + vec vAway = vToEnemy; + vAway.mul(-10.0f); + vGoal.add(vAway); + + vPerp.mul(9.0f); + vGoal.add(vPerp); + } + else if ((flDist > flMaxDist) || + (WeaponInfoTable[m_pMyEnt->gunselect].eWeaponType == TYPE_MELEE)) + { + // Close in on a 45 degree line. The diagonal keeps the same closing + // speed but makes the bot a far harder target than walking straight in. + vPerp.mul(flDist); + vGoal = pEnemy->o; + vGoal.add(vPerp); + } + else if (flDist < flMinDist) + { + // Too close: back off diagonally, still facing the enemy + vec vAway = vToEnemy; + vAway.mul(-6.0f); + vGoal.add(vAway); + + vPerp.mul(4.0f); + vGoal.add(vPerp); + } + else + { + // In range: hold the ideal distance for this weapon and circle the + // enemy on the side with more room + vPerp.mul(6.0f); + vGoal.add(vPerp); + + float flIdealDist = (flMinDist + flMaxDist) / 2.0f; + + if (flDist > flIdealDist + 2.0f) + vGoal.add(vec(vToEnemy).mul(6.0f)); + else if (flDist < flIdealDist - 2.0f) + vGoal.add(vec(vToEnemy).mul(-6.0f)); + } + + SteerTo(vGoal); +} + +bool CBot::EnemyIsHardToReach(playerent *pEnemy) +{ + // Can a gun get to them? A grenade does not care about the answer, which + // is why this decides most lobs: somebody on a ledge, behind a wall or + // round a corner is simply not shootable, and walking there takes long + // enough for them to have moved by the time we arrive. + if (!pEnemy) + return false; + + if (!IsVisible(pEnemy->o)) + return true; + + // The reachability trace walks the ground between the two of us, which is + // far too much work every frame: keep the last answer for a moment + if ((m_pReachEnemy != pEnemy) || (m_iReachCheckTime < lastmillis)) + { + m_pReachEnemy = pEnemy; + m_iReachCheckTime = lastmillis + 500; + m_bEnemyHardToReach = !IsReachable(pEnemy->o); + } + + return m_bEnemyHardToReach; +} + +float CBot::GetGrenadeLobPitch(float flDist, float flHeight, float &flFlight) +{ + // A thrown grenade leaves the hand at 1.5 moveplayer units, which works out + // to 45 cubes a second, and the bounce gravity pulls it down 18.4 cubes a + // second squared. With u = tan(pitch) the flight is: + // height = dist * u - 18.4 * (dist / (45 * cos(pitch)))^2 + // Solving that gives the two launch angles that land on the target; take + // the flat one, so the grenade arrives quickly instead of dropping in from + // the clouds where anybody can walk out of the way. + const float flSpeed = 45.0f; + const float flHalfG = 18.4f; + + flFlight = 0.0f; + + if (flDist < 0.1f) + return 0.0f; + + float flA = flHalfG * flDist * flDist / (flSpeed * flSpeed); + float flDisc = flDist * flDist - 4.0f * flA * (flA + flHeight); + + if (flDisc < 0.0f) + return -999.0f; // too far, or too far up, for the throw to reach + + float flU = (flDist - sqrt(flDisc)) / (2.0f * flA); + float flPitch = atan(flU) * 180.0f / PI; + + // How long it is in the air, to lead a target that is walking + float flCos = cosf(RAD * flPitch); + + if (flCos > 0.01f) + flFlight = flDist / (flSpeed * flCos); + + return flPitch; +} + +void CBot::ReleaseCookedGrenade(void) +{ + // The pin is out and the throw is no longer being lined up (target died, + // ran out of sight, or an escape was more important). A live grenade cannot + // go back into the pocket: throw it now, or a weapon switch would leave it + // in the world at the origin. + if (!m_pBotSkill->bSmartGrenades) + return; + + grenades *pNade = (grenades *)m_pMyEnt->weapons[GUN_GRENADE]; + + if (!pNade || (m_pMyEnt->weaponsel != pNade) || (pNade->state != GST_INHAND)) + return; + + if (m_iGrenadeThrowTime > lastmillis) + return; // still lining the throw up + + m_pMyEnt->attacking = false; + shoot(m_pMyEnt, m_pMyEnt->o); + + m_iGrenadeDelay = lastmillis + RandomLong(2500, 4000); + m_iGrenadeCookTime = 0; +} + +bool CBot::ThrowGrenade(playerent *pTarget) +{ + // Pull the pin, line the throw up and let it go. Returns true while the + // grenade has the bot's full attention: the caller must not fire a gun + // while one is cooking in its hand. + grenades *pNade = (grenades *)m_pMyEnt->weapons[GUN_GRENADE]; + + if (!pNade) + return false; + + // Only the weapon actually in hand can be thrown (a switch takes time) + if (m_pMyEnt->weaponsel != pNade) + return false; + + bool bCooking = (pNade->state == GST_INHAND); + + if (!pTarget || (pTarget->state != CS_ALIVE)) + return bCooking; // a live grenade is handled by ReleaseCookedGrenade() + + if (!bCooking && !m_pMyEnt->mag[GUN_GRENADE]) + return false; + + float flDist = GetDistance(pTarget->o); + + if (!bCooking && !ShouldThrowGrenade(flDist, pTarget)) + return false; + + // Where it has to land. The arc depends on the distance along the ground + // and the height difference, not on the straight line between the two + // origins - feeding it the 3D distance throws the grenade short. Lead them + // by the flight time as well. + vec vAim = pTarget->o; + float flFlight = 0.0f; + float flHoriz = Get2DDistance(vAim); + float flPitch = GetGrenadeLobPitch(flHoriz, vAim.z - m_pMyEnt->o.z, flFlight); + + if (flPitch < -900.0f) + return false; + + if (flFlight > 0.0f) + { + vAim.add(vec(pTarget->vel).mul(flFlight)); + + flHoriz = Get2DDistance(vAim); + flPitch = GetGrenadeLobPitch(flHoriz, vAim.z - m_pMyEnt->o.z, flFlight); + + if (flPitch < -900.0f) + return false; + } + + AimToVec(vAim); + + // The aim yaw points at the target, the pitch is the arc that gets the + // grenade there. Both are what the throw itself will use, so they have to + // have settled before the pin comes out. + m_pMyEnt->targetpitch = clamp(flPitch, -30.0f, 60.0f); + + float flYawOff = fabs(WrapYZAngle(m_pMyEnt->yaw - m_pMyEnt->targetyaw)); + float flPitchOff = fabs(WrapYZAngle(m_pMyEnt->pitch - m_pMyEnt->targetpitch)); + + // Stand still while lining it up: a bot that keeps strafe running has its + // view pointed 45 degrees off the target and throws into a wall + m_iGrenadeThrowTime = lastmillis + 500; + + if (!bCooking) + { + // Under fire it is better to throw roughly right than to stand and + // aim: after a second of cooking it goes anyway + if ((flYawOff > 5.0f) || (flPitchOff > 5.0f)) + return true; + + m_pMyEnt->attacking = true; // pull the pin + m_iGrenadeCookTime = lastmillis; + shoot(m_pMyEnt, vAim); + return true; + } + + bool bForced = (lastmillis - m_iGrenadeCookTime) > 1000; + + if (!bForced && ((flYawOff > 5.0f) || (flPitchOff > 5.0f))) + return true; + + // Hold on to it for a moment on purpose: the fuse is two seconds from the + // pin, so a grenade let go straight away lands and then lies there for + // two seconds while the target walks away. Burning ~0.7s of fuse brings + // the bang close to the landing + if (!bForced && ((lastmillis - m_iGrenadeCookTime) < 700)) + return true; + + // In hand and not attacking: this is the throw + m_pMyEnt->attacking = false; + shoot(m_pMyEnt, vAim); + + if (pNade->state == GST_THROWING) + { + m_iGrenadeDelay = lastmillis + RandomLong(2500, 4000); + m_iGrenadeCookTime = 0; + m_iGrenadeThrowTime = 0; + } + + return true; +} + +bool CBot::ShouldThrowGrenade(float flDist, playerent *pAimAt) +{ + if (!m_pMyEnt->mag[GUN_GRENADE]) + return false; + + if (m_iGrenadeDelay > lastmillis) + return false; + + if (!pAimAt) + pAimAt = m_pMyEnt->enemy; + + if (!pAimAt || (pAimAt->state != CS_ALIVE)) + return false; + + // A grenade earns its place when a bullet does not: the enemy is somewhere + // a gun cannot reach, the bot is in no shape to trade shots, or the gun in + // hand is simply out of the range where it still hits anything + bool bHardToReach = EnemyIsHardToReach(pAimAt); + + bool bUnsafe = (m_pMyEnt->health <= 45) || !m_pMyEnt->mag[m_pMyEnt->gunselect]; + + bool bOutOfRange = false; + + if (m_pMyEnt->gunselect != GUN_GRENADE) + { + float flMin, flMax; + GetPreferredRange(flMin, flMax); + bOutOfRange = (flDist > flMax + 5.0f); + } + + bool bLob = bHardToReach || bUnsafe || bOutOfRange; + + // The blast has a radius of 10 cubes and the fuse burns for two seconds: + // throw from inside our own blast and the finishing move is a suicide, so + // stay a couple of cubes clear of it (the target likes to walk towards us). + // A lob can be thrown further and closer, it is an arcing gamble. + float flMinDist = bLob ? 7.0f : 13.0f; + float flMaxDist = bLob ? 42.0f : 26.0f; + + if ((flDist < flMinDist) || (flDist > flMaxDist)) + return false; + + // Does the arc even get there from here? + float flFlight; + if (GetGrenadeLobPitch(Get2DDistance(pAimAt->o), pAimAt->o.z - m_pMyEnt->o.z, flFlight) < -900.0f) + return false; + + // Don't bounce it straight back into our own face + if (!bLob && !IsVisible(m_pMyEnt->o, FORWARD, 5.0f, false)) + return false; + + // Never nade a spot a team mate is standing on + vec vImpact = pAimAt->o; + + loopv(bots) + { + playerent *d = bots[i]; + + if (!d || (d == m_pMyEnt) || (d->state != CS_ALIVE)) + continue; + + if (!isteam(d->team, m_pMyEnt->team)) + continue; + + if (GetDistance(d->o, vImpact) <= 10.0f) + return false; + } + + if (player1 && (player1->state == CS_ALIVE) && + isteam(player1->team, m_pMyEnt->team) && + (GetDistance(player1->o, vImpact) <= 10.0f)) + return false; + + return true; +} + +void CBot::AvoidGrenades(void) +{ + // The blast has a radius of 10 cubes, so start running well before that: a + // grenade keeps sliding while the fuse burns, and two seconds go by fast + const float flFleeRadius = 15.0f; + const float flFleeDist = 16.0f; + + if (m_pMyEnt->state != CS_ALIVE) + return; + + // Deal with the grenade that is closest to going off in our face + bounceent *pThreat = NULL; + float flThreatDist = flFleeRadius; + + loopv(bounceents) + { + bounceent *b = bounceents[i]; + + if (!b || (b->bouncetype != BT_NADE) || !b->isalive(lastmillis) || + (b->owner == m_pMyEnt)) + continue; + + float flDist = GetDistance(b->o); + + if (flDist < flThreatDist) + { + flThreatDist = flDist; + pThreat = b; + } + } + + if (!pThreat) + { + // No live grenade yet, but somebody is standing there cooking one at + // us: don't be where it is going to land. Sideways is the answer, it + // gets us off the throwing line no matter how well they lead us. + playerent *pThrower = GetGrenadeThrower(); + + if (!pThrower) + return; + + vec vSide; + vec vFrom = m_pMyEnt->o; + vFrom.sub(pThrower->o); + vFrom.z = 0.0f; + + if (vFrom.magnitude() < 0.1f) + vFrom = vec(1.0f, 0.0f, 0.0f); + else + vFrom.normalize(); + + vSide = vec(vFrom.y, -vFrom.x, 0.0f); + + if (GetMoveClearance(vSide, 8.0f) < GetMoveClearance(vec(-vSide.x, -vSide.y, 0.0f), 8.0f)) + vSide.mul(-1.0f); + + vec vEscape = m_pMyEnt->o; + + // Close enough to hurt us: get out of the blast, not just off the line + if (GetDistance(pThrower->o) < 14.0f) + vEscape.add(vec(vFrom).mul(5.0f)); + + vEscape.add(vec(vSide).mul(11.0f)); + + // Running beats finishing the throw: drop the aim and move + m_iGrenadeThrowTime = 0; + + SteerTo(vEscape, true); + return; + } + + vec vAway = m_pMyEnt->o; + vAway.sub(pThreat->o); + vAway.z = 0.0f; + + if (vAway.magnitude() < 0.1f) + vAway = vec(1.0f, 0.0f, 0.0f); + else + vAway.normalize(); + + // Away and sideways: running straight back is usually straight into the + // wall behind us, and standing in a corner next to a grenade is death + vec vSide(vAway.y, -vAway.x, 0.0f); + + if (GetMoveClearance(vSide, 6.0f) < GetMoveClearance(vec(-vSide.x, -vSide.y, 0.0f), 6.0f)) + vSide.mul(-1.0f); + + vec vEscape = vAway; + vEscape.add(vec(vSide).mul(0.7f)); + vEscape.normalize().mul(flFleeDist); + vEscape.add(m_pMyEnt->o); + vEscape.z = m_pMyEnt->o.z; + + // Right next to it: a jump gets us off the floor and a cube or so further + // away over the fuse, which is enough to survive + if ((flThreatDist < 7.0f) && m_pMyEnt->onfloor) + m_pMyEnt->jumpnext = true; + + // Sprint out of there: two keys are 1.4x faster than jogging backwards and + // the view turns with the escape, so the bot gets out of the blast instead + // of shuffling out of it while still aiming at the enemy + m_iGrenadeThrowTime = 0; + SteerTo(vEscape, true); +} + +void CBot::GetPreferredRange(float &flMinDist, float &flMaxDist) +{ + flMinDist = WeaponInfoTable[m_pMyEnt->gunselect].flMinDesiredDistance; + flMaxDist = WeaponInfoTable[m_pMyEnt->gunselect].flMaxDesiredDistance; +} + +int CBot::GetEnemyScore(playerent *d, float flDist) +{ + // Elite bots don't just pick whoever is closest: they go for the biggest + // threat and finish off whoever is about to die + int iScore = 200 - int(flDist * 2.0f); + if (iScore < 0) iScore = 0; + + // Someone aiming at us is the one that is going to shoot us + if (fabs(WrapYZAngle(GetYawDiff(d->yaw, d->o, m_pMyEnt->o))) < 40.0f) + iScore += 120; + + // Finish off weak enemies instead of spreading the damage around + iScore += (100 - d->health); + + // Targets we already look at die faster (less turning, more hits) + if (IsInFOV(d)) + iScore += 60; + + // Don't hug a shotgunner with a sniper rifle + if ((flDist < 5.0f) && + (WeaponInfoTable[d->gunselect].eWeaponType == TYPE_SHOTGUN)) + iScore -= 40; + + return iScore; +} // Code of CBot - End diff --git a/source/src/bot/bot_util.cpp b/source/src/bot/bot_util.cpp index 5257d46a..4a84e2fa 100644 --- a/source/src/bot/bot_util.cpp +++ b/source/src/bot/bot_util.cpp @@ -450,11 +450,12 @@ float GetCubeHeight(int x, int y) const char *SkillNrToSkillName(short skillnr) { - if (skillnr == 0) return "best"; - else if (skillnr == 1) return "good"; - else if (skillnr == 2) return "medium"; - else if (skillnr == 3) return "worse"; - else if (skillnr == 4) return "bad"; + if (skillnr == 0) return "best+"; + else if (skillnr == 1) return "best"; + else if (skillnr == 2) return "good"; + else if (skillnr == 3) return "medium"; + else if (skillnr == 4) return "worse"; + else if (skillnr == 5) return "bad"; return NULL; } diff --git a/source/src/bot/bot_waypoint.cpp b/source/src/bot/bot_waypoint.cpp index 8dbeec50..f7486429 100644 --- a/source/src/bot/bot_waypoint.cpp +++ b/source/src/bot/bot_waypoint.cpp @@ -1965,8 +1965,11 @@ bool CBot::FindWaypoint() // about 20% of the time choose a waypoint at random // (don't do this any more often than every 10 seconds) + // Elite bots should look purposeful, so they do this far less often - if ((RandomLong(1, 100) <= 20) && (m_iRandomWaypointTime <= lastmillis)) + short sRandomPercent = m_pBotSkill->bSmartMovement ? 5 : 20; + + if ((RandomLong(1, 100) <= sRandomPercent) && (m_iRandomWaypointTime <= lastmillis)) { m_iRandomWaypointTime = lastmillis + 10000; @@ -2148,11 +2151,41 @@ bool CBot::HeadToWaypoint() m_iWaypointTime += 200; } - if (m_iWaypointHeadPauseTime >= lastmillis) + // Elite bots keep moving while they turn: the steering below decides what + // is safe to do + if (!m_pBotSkill->bSmartMovement && (m_iWaypointHeadPauseTime >= lastmillis)) { m_pMyEnt->move = 0; //conoutf("Pause in HeadToWaypoint()"); } + else if (m_pBotSkill->bSmartMovement) + { + vec vGoal = m_pCurrentWaypoint->pNode->v_origin; + vec vDir = vGoal; + vDir.sub(m_pMyEnt->o); + vDir.z = 0.0f; + + float flGoalDist = vDir.magnitude(); + + if (flGoalDist > 1.0f) + { + vDir.div(flGoalDist); + + // Low wall on the way? Hop it. Anything higher is walked around + // by the steering below, so the bot stops bumping into things. + float flClear = GetMoveClearance(vDir, 3.0f); + + if ((flClear < 3.0f) && (flGoalDist > flClear)) + { + m_pMyEnt->jumpnext = true; + condebug("Low wall in HeadToWaypoint()"); + } + } + + // Strafe-run: two keys are ~1.4x faster, and the 45 degree aim offset + // makes the combined movement point straight at the waypoint + SteerTo(vGoal, true); + } else { // Check if bot has to jump @@ -2664,6 +2697,8 @@ void CBot::ResetWaypointVars() m_AStarNodeList.DeleteAllNodes(); CleanAStarLists(false); m_bGoToDebugGoal = false; + m_vWanderGoal = g_vecZero; + m_iWanderTime = 0; } void CBot::SetCurrentWaypoint(node_s *pNode) diff --git a/source/src/bot/botmanager.cpp b/source/src/bot/botmanager.cpp index 7f659979..b23f0949 100644 --- a/source/src/bot/botmanager.cpp +++ b/source/src/bot/botmanager.cpp @@ -31,7 +31,7 @@ void CBotManager::Init() m_iFrameTime = 0; m_iPrevTime = lastmillis; - m_sBotSkill = 1; // Default all bots have the skill 'Good' + m_sBotSkill = 2; // Default all bots have the skill 'Good' CreateSkillData(); LoadBotNamesFile(); @@ -543,6 +543,8 @@ void CBotManager::CreateSkillData() SkillNr = 3; else if (strcasecmp(cmd, "[SKILL5]") == 0) SkillNr = 4; + else if (strcasecmp(cmd, "[SKILL6]") == 0) + SkillNr = 5; if ((arg1 == NULL) || (*arg1 == 0)) continue; @@ -628,6 +630,26 @@ void CBotManager::CreateSkillData() { m_BotSkills[SkillNr].bCanSearchItemsInCombat = value!=0; } + else if (strcasecmp(cmd, "smart_movement") == 0) + { + m_BotSkills[SkillNr].bSmartMovement = value!=0; + } + else if (strcasecmp(cmd, "smart_weapons") == 0) + { + m_BotSkills[SkillNr].bSmartWeapons = value!=0; + } + else if (strcasecmp(cmd, "smart_grenades") == 0) + { + m_BotSkills[SkillNr].bSmartGrenades = value!=0; + } + else if (strcasecmp(cmd, "smart_targeting") == 0) + { + m_BotSkills[SkillNr].bSmartTargeting = value!=0; + } + else if (strcasecmp(cmd, "dodge_fire") == 0) + { + m_BotSkills[SkillNr].bDodgeFire = value!=0; + } } conoutf("done"); @@ -635,120 +657,160 @@ void CBotManager::CreateSkillData() void CBotManager::InitSkillData() { - // Best skill - m_BotSkills[0].flMinReactionDelay = 0.015f; - m_BotSkills[0].flMaxReactionDelay = 0.035f; - m_BotSkills[0].flMinAimXOffset = 15.0f; - m_BotSkills[0].flMaxAimXOffset = 20.0f; - m_BotSkills[0].flMinAimYOffset = 10.0f; - m_BotSkills[0].flMaxAimYOffset = 15.0f; - m_BotSkills[0].flMinAimXSpeed = 330.0f; - m_BotSkills[0].flMaxAimXSpeed = 355.0f; - m_BotSkills[0].flMinAimYSpeed = 400.0f; - m_BotSkills[0].flMaxAimYSpeed = 450.0f; - m_BotSkills[0].flMinAttackDelay = 0.1f; - m_BotSkills[0].flMaxAttackDelay = 0.4f; - m_BotSkills[0].flMinEnemySearchDelay = 0.09f; - m_BotSkills[0].flMaxEnemySearchDelay = 0.12f; - m_BotSkills[0].flAlwaysDetectDistance = 12.0f; + // The elite features are only enabled for the best+ skill below + for (int i=0;i<6;i++) + { + m_BotSkills[i].bSmartMovement = false; + m_BotSkills[i].bSmartWeapons = false; + m_BotSkills[i].bSmartGrenades = false; + m_BotSkills[i].bSmartTargeting = false; + m_BotSkills[i].bDodgeFire = false; + } + + // Best+ skill (elite) + m_BotSkills[0].flMinReactionDelay = 0.005f; + m_BotSkills[0].flMaxReactionDelay = 0.015f; + m_BotSkills[0].flMinAimXOffset = 0.0f; + m_BotSkills[0].flMaxAimXOffset = 2.0f; + m_BotSkills[0].flMinAimYOffset = 0.0f; + m_BotSkills[0].flMaxAimYOffset = 2.0f; + m_BotSkills[0].flMinAimXSpeed = 450.0f; + m_BotSkills[0].flMaxAimXSpeed = 520.0f; + m_BotSkills[0].flMinAimYSpeed = 500.0f; + m_BotSkills[0].flMaxAimYSpeed = 560.0f; + m_BotSkills[0].flMinAttackDelay = 0.02f; + m_BotSkills[0].flMaxAttackDelay = 0.08f; + m_BotSkills[0].flMinEnemySearchDelay = 0.04f; + m_BotSkills[0].flMaxEnemySearchDelay = 0.07f; + m_BotSkills[0].flAlwaysDetectDistance = 20.0f; m_BotSkills[0].bCanPredict = true; - m_BotSkills[0].iMaxHearVolume = 75; - m_BotSkills[0].iFov = 200; + m_BotSkills[0].iMaxHearVolume = 100; + m_BotSkills[0].iFov = 240; m_BotSkills[0].bCircleStrafe = true; m_BotSkills[0].bCanSearchItemsInCombat = true; + m_BotSkills[0].bSmartMovement = true; + m_BotSkills[0].bSmartWeapons = true; + m_BotSkills[0].bSmartGrenades = true; + m_BotSkills[0].bSmartTargeting = true; + m_BotSkills[0].bDodgeFire = true; - // Good skill - m_BotSkills[1].flMinReactionDelay = 0.035f; - m_BotSkills[1].flMaxReactionDelay = 0.045f; - m_BotSkills[1].flMinAimXOffset = 20.0f; - m_BotSkills[1].flMaxAimXOffset = 25.0f; - m_BotSkills[1].flMinAimYOffset = 15.0f; - m_BotSkills[1].flMaxAimYOffset = 20.0f; - m_BotSkills[1].flMinAimXSpeed = 250.0f; - m_BotSkills[1].flMaxAimXSpeed = 265.0f; - m_BotSkills[1].flMinAimYSpeed = 260.0f; - m_BotSkills[1].flMaxAimYSpeed = 285.0f; - m_BotSkills[1].flMinAttackDelay = 0.3f; - m_BotSkills[1].flMaxAttackDelay = 0.6f; - m_BotSkills[1].flMinEnemySearchDelay = 0.12f; - m_BotSkills[1].flMaxEnemySearchDelay = 0.17f; - m_BotSkills[1].flAlwaysDetectDistance = 10.0f; + // Best skill + m_BotSkills[1].flMinReactionDelay = 0.015f; + m_BotSkills[1].flMaxReactionDelay = 0.035f; + m_BotSkills[1].flMinAimXOffset = 15.0f; + m_BotSkills[1].flMaxAimXOffset = 20.0f; + m_BotSkills[1].flMinAimYOffset = 10.0f; + m_BotSkills[1].flMaxAimYOffset = 15.0f; + m_BotSkills[1].flMinAimXSpeed = 330.0f; + m_BotSkills[1].flMaxAimXSpeed = 355.0f; + m_BotSkills[1].flMinAimYSpeed = 400.0f; + m_BotSkills[1].flMaxAimYSpeed = 450.0f; + m_BotSkills[1].flMinAttackDelay = 0.1f; + m_BotSkills[1].flMaxAttackDelay = 0.4f; + m_BotSkills[1].flMinEnemySearchDelay = 0.09f; + m_BotSkills[1].flMaxEnemySearchDelay = 0.12f; + m_BotSkills[1].flAlwaysDetectDistance = 12.0f; m_BotSkills[1].bCanPredict = true; - m_BotSkills[1].iMaxHearVolume = 60; - m_BotSkills[1].iFov = 160; + m_BotSkills[1].iMaxHearVolume = 75; + m_BotSkills[1].iFov = 200; m_BotSkills[1].bCircleStrafe = true; m_BotSkills[1].bCanSearchItemsInCombat = true; - // Medium skill - m_BotSkills[2].flMinReactionDelay = 0.075f; - m_BotSkills[2].flMaxReactionDelay = 0.010f; - m_BotSkills[2].flMinAimXOffset = 25.0f; - m_BotSkills[2].flMaxAimXOffset = 30.0f; - m_BotSkills[2].flMinAimYOffset = 20.0f; - m_BotSkills[2].flMaxAimYOffset = 25.0f; - m_BotSkills[2].flMinAimXSpeed = 190.0f; - m_BotSkills[2].flMaxAimXSpeed = 125.0f; - m_BotSkills[2].flMinAimYSpeed = 210.0f; - m_BotSkills[2].flMaxAimYSpeed = 240.0f; - m_BotSkills[2].flMinAttackDelay = 0.75f; - m_BotSkills[2].flMaxAttackDelay = 1.0f; - m_BotSkills[2].flMinEnemySearchDelay = 0.18f; - m_BotSkills[2].flMaxEnemySearchDelay = 0.22f; - m_BotSkills[2].flAlwaysDetectDistance = 8.0f; - m_BotSkills[2].bCanPredict = false; - m_BotSkills[2].iMaxHearVolume = 45; - m_BotSkills[2].iFov = 130; + // Good skill + m_BotSkills[2].flMinReactionDelay = 0.035f; + m_BotSkills[2].flMaxReactionDelay = 0.045f; + m_BotSkills[2].flMinAimXOffset = 20.0f; + m_BotSkills[2].flMaxAimXOffset = 25.0f; + m_BotSkills[2].flMinAimYOffset = 15.0f; + m_BotSkills[2].flMaxAimYOffset = 20.0f; + m_BotSkills[2].flMinAimXSpeed = 250.0f; + m_BotSkills[2].flMaxAimXSpeed = 265.0f; + m_BotSkills[2].flMinAimYSpeed = 260.0f; + m_BotSkills[2].flMaxAimYSpeed = 285.0f; + m_BotSkills[2].flMinAttackDelay = 0.3f; + m_BotSkills[2].flMaxAttackDelay = 0.6f; + m_BotSkills[2].flMinEnemySearchDelay = 0.12f; + m_BotSkills[2].flMaxEnemySearchDelay = 0.17f; + m_BotSkills[2].flAlwaysDetectDistance = 10.0f; + m_BotSkills[2].bCanPredict = true; + m_BotSkills[2].iMaxHearVolume = 60; + m_BotSkills[2].iFov = 160; m_BotSkills[2].bCircleStrafe = true; - m_BotSkills[2].bCanSearchItemsInCombat = false; + m_BotSkills[2].bCanSearchItemsInCombat = true; - // Worse skill - m_BotSkills[3].flMinReactionDelay = 0.15f; - m_BotSkills[3].flMaxReactionDelay = 0.20f; - m_BotSkills[3].flMinAimXOffset = 30.0f; - m_BotSkills[3].flMaxAimXOffset = 35.0f; - m_BotSkills[3].flMinAimYOffset = 25.0f; - m_BotSkills[3].flMaxAimYOffset = 30.0f; - m_BotSkills[3].flMinAimXSpeed = 155.0f; - m_BotSkills[3].flMaxAimXSpeed = 170.0f; - m_BotSkills[3].flMinAimYSpeed = 160.0f; - m_BotSkills[3].flMaxAimYSpeed = 210.0f; - m_BotSkills[3].flMinAttackDelay = 1.2f; - m_BotSkills[3].flMaxAttackDelay = 1.6f; - m_BotSkills[3].flMinEnemySearchDelay = 0.25f; - m_BotSkills[3].flMaxEnemySearchDelay = 0.30f; - m_BotSkills[3].flAlwaysDetectDistance = 6.0f; + // Medium skill + m_BotSkills[3].flMinReactionDelay = 0.075f; + m_BotSkills[3].flMaxReactionDelay = 0.010f; + m_BotSkills[3].flMinAimXOffset = 25.0f; + m_BotSkills[3].flMaxAimXOffset = 30.0f; + m_BotSkills[3].flMinAimYOffset = 20.0f; + m_BotSkills[3].flMaxAimYOffset = 25.0f; + m_BotSkills[3].flMinAimXSpeed = 190.0f; + m_BotSkills[3].flMaxAimXSpeed = 125.0f; + m_BotSkills[3].flMinAimYSpeed = 210.0f; + m_BotSkills[3].flMaxAimYSpeed = 240.0f; + m_BotSkills[3].flMinAttackDelay = 0.75f; + m_BotSkills[3].flMaxAttackDelay = 1.0f; + m_BotSkills[3].flMinEnemySearchDelay = 0.18f; + m_BotSkills[3].flMaxEnemySearchDelay = 0.22f; + m_BotSkills[3].flAlwaysDetectDistance = 8.0f; m_BotSkills[3].bCanPredict = false; - m_BotSkills[3].iMaxHearVolume = 30; - m_BotSkills[3].iFov = 120; - m_BotSkills[3].bCircleStrafe = false; + m_BotSkills[3].iMaxHearVolume = 45; + m_BotSkills[3].iFov = 130; + m_BotSkills[3].bCircleStrafe = true; m_BotSkills[3].bCanSearchItemsInCombat = false; - // Bad skill - m_BotSkills[4].flMinReactionDelay = 0.30f; - m_BotSkills[4].flMaxReactionDelay = 0.50f; - m_BotSkills[4].flMinAimXOffset = 35.0f; - m_BotSkills[4].flMaxAimXOffset = 40.0f; - m_BotSkills[4].flMinAimYOffset = 30.0f; - m_BotSkills[4].flMaxAimYOffset = 35.0f; - m_BotSkills[4].flMinAimXSpeed = 45.0f; - m_BotSkills[4].flMaxAimXSpeed = 60.0f; - m_BotSkills[4].flMinAimYSpeed = 125.0f; - m_BotSkills[4].flMaxAimYSpeed = 180.0f; - m_BotSkills[4].flMinAttackDelay = 1.5f; - m_BotSkills[4].flMaxAttackDelay = 2.0f; - m_BotSkills[4].flMinEnemySearchDelay = 0.30f; - m_BotSkills[4].flMaxEnemySearchDelay = 0.36f; - m_BotSkills[4].flAlwaysDetectDistance = 4.0f; + // Worse skill + m_BotSkills[4].flMinReactionDelay = 0.15f; + m_BotSkills[4].flMaxReactionDelay = 0.20f; + m_BotSkills[4].flMinAimXOffset = 30.0f; + m_BotSkills[4].flMaxAimXOffset = 35.0f; + m_BotSkills[4].flMinAimYOffset = 25.0f; + m_BotSkills[4].flMaxAimYOffset = 30.0f; + m_BotSkills[4].flMinAimXSpeed = 155.0f; + m_BotSkills[4].flMaxAimXSpeed = 170.0f; + m_BotSkills[4].flMinAimYSpeed = 160.0f; + m_BotSkills[4].flMaxAimYSpeed = 210.0f; + m_BotSkills[4].flMinAttackDelay = 1.2f; + m_BotSkills[4].flMaxAttackDelay = 1.6f; + m_BotSkills[4].flMinEnemySearchDelay = 0.25f; + m_BotSkills[4].flMaxEnemySearchDelay = 0.30f; + m_BotSkills[4].flAlwaysDetectDistance = 6.0f; m_BotSkills[4].bCanPredict = false; - m_BotSkills[4].iMaxHearVolume = 15; - m_BotSkills[4].iFov = 110; + m_BotSkills[4].iMaxHearVolume = 30; + m_BotSkills[4].iFov = 120; m_BotSkills[4].bCircleStrafe = false; m_BotSkills[4].bCanSearchItemsInCombat = false; + + // Bad skill + m_BotSkills[5].flMinReactionDelay = 0.30f; + m_BotSkills[5].flMaxReactionDelay = 0.50f; + m_BotSkills[5].flMinAimXOffset = 35.0f; + m_BotSkills[5].flMaxAimXOffset = 40.0f; + m_BotSkills[5].flMinAimYOffset = 30.0f; + m_BotSkills[5].flMaxAimYOffset = 35.0f; + m_BotSkills[5].flMinAimXSpeed = 45.0f; + m_BotSkills[5].flMaxAimXSpeed = 60.0f; + m_BotSkills[5].flMinAimYSpeed = 125.0f; + m_BotSkills[5].flMaxAimYSpeed = 180.0f; + m_BotSkills[5].flMinAttackDelay = 1.5f; + m_BotSkills[5].flMaxAttackDelay = 2.0f; + m_BotSkills[5].flMinEnemySearchDelay = 0.30f; + m_BotSkills[5].flMaxEnemySearchDelay = 0.36f; + m_BotSkills[5].flAlwaysDetectDistance = 4.0f; + m_BotSkills[5].bCanPredict = false; + m_BotSkills[5].iMaxHearVolume = 15; + m_BotSkills[5].iFov = 110; + m_BotSkills[5].bCircleStrafe = false; + m_BotSkills[5].bCanSearchItemsInCombat = false; } void CBotManager::ChangeBotSkill(short Skill, botent *bot) { - static const char *SkillNames[5] = { "best", "good", "medium", "worse", "bad" }; + static const char *SkillNames[6] = { "best+", "best", "good", "medium", "worse", "bad" }; + + if ((Skill < 0) || (Skill > 5)) + return; if (bot && bot->pBot) { @@ -898,15 +960,16 @@ botent *CBotManager::CreateBot(const char *team, const char *skill, const char * if (skill && *skill && strcmp(skill, "random")) { - if (!strcasecmp(skill, "best")) m->pBot->m_sSkillNr = 0; - else if (!strcasecmp(skill, "good")) m->pBot->m_sSkillNr = 1; - else if (!strcasecmp(skill, "medium")) m->pBot->m_sSkillNr = 2; - else if (!strcasecmp(skill, "worse")) m->pBot->m_sSkillNr = 3; - else if (!strcasecmp(skill, "bad")) m->pBot->m_sSkillNr = 4; + if (!strcasecmp(skill, "best+")) m->pBot->m_sSkillNr = 0; + else if (!strcasecmp(skill, "best")) m->pBot->m_sSkillNr = 1; + else if (!strcasecmp(skill, "good")) m->pBot->m_sSkillNr = 2; + else if (!strcasecmp(skill, "medium")) m->pBot->m_sSkillNr = 3; + else if (!strcasecmp(skill, "worse")) m->pBot->m_sSkillNr = 4; + else if (!strcasecmp(skill, "bad")) m->pBot->m_sSkillNr = 5; else { - conoutf("Wrong skill specified. Should be best, good, medium, " - "worse or bad"); + conoutf("Wrong skill specified. Should be best+, best, good, " + "medium, worse or bad"); conoutf("Using default skill instead..."); m->pBot->m_sSkillNr = BotManager.m_sBotSkill; } @@ -917,6 +980,7 @@ botent *CBotManager::CreateBot(const char *team, const char *skill, const char * // Sync waypoints m->pBot->SyncWaypoints(); m->pBot->Spawn(); + m->pBot->RollTemperament(); bots.add(m); return m; } @@ -1075,19 +1139,21 @@ void botskill(char *bot, char *skill) short SkillNr; - if (!strcasecmp(skill, "best")) + if (!strcasecmp(skill, "best+")) SkillNr = 0; - else if (!strcasecmp(skill, "good")) + else if (!strcasecmp(skill, "best")) SkillNr = 1; - else if (!strcasecmp(skill, "medium")) + else if (!strcasecmp(skill, "good")) SkillNr = 2; - else if (!strcasecmp(skill, "worse")) + else if (!strcasecmp(skill, "medium")) SkillNr = 3; - else if (!strcasecmp(skill, "bad")) + else if (!strcasecmp(skill, "worse")) SkillNr = 4; + else if (!strcasecmp(skill, "bad")) + SkillNr = 5; else { - conoutf("Wrong skill specified. Should be best, good, medium, worse or bad"); + conoutf("Wrong skill specified. Should be best+, best, good, medium, worse or bad"); return; } diff --git a/source/src/client.cpp b/source/src/client.cpp index d4711a0a..75b87f80 100644 --- a/source/src/client.cpp +++ b/source/src/client.cpp @@ -41,12 +41,14 @@ void throttle() enet_peer_throttle_configure(curpeer, throttle_interval*1000, throttle_accel, throttle_decel); } +string clientpassword = ""; int connectrole = CR_DEFAULT; bool modprotocol = false; void abortconnect() { if(!connpeer) return; + clientpassword[0] = '\0'; connectrole = CR_DEFAULT; if(connpeer->state!=ENET_PEER_STATE_DISCONNECTED) enet_peer_reset(connpeer); connpeer = NULL; @@ -63,6 +65,7 @@ void connectserv_(const char *servername, int serverport = 0, const char *passwo abortconnect(); } connectrole = role; + copystring(clientpassword, password ? password : ""); ENetAddress address; address.port = serverport; @@ -121,9 +124,9 @@ COMMANDN(connect, connectserv, "si"); void connectadmin(char *servername, int *serverport, char *password) { modprotocol = false; - connectserv_(servername, *serverport, NULL, CR_ADMIN); + connectserv_(servername, *serverport, password, CR_ADMIN); } -COMMAND(connectadmin, "si"); +COMMAND(connectadmin, "sis"); void lanconnect() { @@ -142,9 +145,9 @@ COMMANDN(modconnect, modconnectserv, "si"); void modconnectadmin(char *servername, int *serverport, char *password) { modprotocol = true; - connectserv_(servername, *serverport, NULL, CR_ADMIN); + connectserv_(servername, *serverport, password, CR_ADMIN); } -COMMAND(modconnectadmin, "si"); +COMMAND(modconnectadmin, "sis"); void modlanconnect() { @@ -511,9 +514,10 @@ void sendintro() putint(p, AC_VERSION); putint(p, getbuildtype()); sendstring(player1->name, p); - sendstring("", p); + sendstring(genpwdhash(player1->name, clientpassword, sessionid), p); sendstring(!lang || strlen(lang) != 2 ? "" : lang, p); putint(p, connectrole); + clientpassword[0] = '\0'; connectrole = CR_DEFAULT; putint(p, player1->nextprimweap->type); loopi(2) putint(p, player1->skin(i)); diff --git a/source/src/physics.cpp b/source/src/physics.cpp index fef8a46a..2592a5ee 100644 --- a/source/src/physics.cpp +++ b/source/src/physics.cpp @@ -432,9 +432,10 @@ extern int matchsourcephysics; static inline void sourcefriction(vec &vel, float dt) { float speed = vel.magnitudexy(); - if(speed < 0.01f) return; + if(speed <= 0.0f) return; float control = max(speed, SRC_STOPSPEED); float newspeed = max(speed - control*SRC_FRICTION*dt, 0.0f) / speed; + if(newspeed <= 0.0f) { vel.x = vel.y = 0.0f; return; } // stop completely - no residual gliding at low speed vel.x *= newspeed; vel.y *= newspeed; } @@ -471,6 +472,7 @@ void moveplayer(physent *pl, int moveres, bool local, int curtime) vec d; // vector of direction we ideally want to move in float drop = 0, rise = 0; + float srcspeed = 1.0f; // displacement per unit of velocity, to convert d back to velocity (Source physics) if(pl->type==ENT_BOUNCE) { @@ -521,6 +523,7 @@ void moveplayer(physent *pl, int moveres, bool local, int curtime) } const float speed = curtime/(water ? 2000.0f : 1000.0f)*pl->maxspeed*weaponspeedmul*(matchspeedmul ? matchspeedmul / 100.0f : 1.0f)*(crouching && pl->state != CS_EDITING ? chspeed : 1.0f)*(pl==player1 && isfly ? flyspeed : 1.0f); + srcspeed = speed; // the constants below are really "inertia" (higher = slides more), so more friction means dividing them const float friction = (water ? 20.0f : (pl->onfloor || isfly ? 6.0f : (pl->onladder ? 1.5f : 30.0f))) * 100.0f / clamp(matchfriction, 10, 500); const float fpsfric = max(friction/curtime*20.0f, 1.0f); @@ -786,6 +789,7 @@ void moveplayer(physent *pl, int moveres, bool local, int curtime) if(!collide(pl, false, drop, rise)) { d.x = vd.x; d.y = vd.y; + if(srcphys && srcspeed > 1e-6f) { pl->vel.x = vd.x/srcspeed; pl->vel.y = vd.y/srcspeed; } // slide along the corner, don't keep the velocity into it continue; } pl->o = o_nullxy; @@ -796,6 +800,7 @@ void moveplayer(physent *pl, int moveres, bool local, int curtime) { d.x = 0; if(pl->type==ENT_BOUNCE) { pl->vel.x = -pl->vel.x; pl->vel.mul(0.7f); } + else if(srcphys) pl->vel.x = 0.0f; // wall stopped us: drop the velocity into it continue; } pl->o = o_nullxy; @@ -805,6 +810,7 @@ void moveplayer(physent *pl, int moveres, bool local, int curtime) { d.y = 0; if(pl->type==ENT_BOUNCE) { pl->vel.y = -pl->vel.y; pl->vel.mul(0.7f); } + else if(srcphys) pl->vel.y = 0.0f; // wall stopped us: drop the velocity into it continue; } pl->o = o_nullxy; @@ -812,10 +818,12 @@ void moveplayer(physent *pl, int moveres, bool local, int curtime) if(!collide(pl, false, drop, rise)) { d.y = d.x = 0; + if(srcphys) pl->vel.x = pl->vel.y = 0.0f; continue; } pl->o = o_null; if(pl->type==ENT_BOUNCE) { pl->vel.z = -pl->vel.z; pl->vel.mul(0.5f); } + else if(srcphys) pl->vel.x = pl->vel.y = 0.0f; // nothing worked: don't keep pushing into whatever blocks us break; } diff --git a/source/src/weapon.cpp b/source/src/weapon.cpp index 069b4c58..df329694 100644 --- a/source/src/weapon.cpp +++ b/source/src/weapon.cpp @@ -1449,7 +1449,9 @@ void grenades::onselecting(bool sound) void grenades::onownerdies() { reset(); - if(owner==player1 && inhandnade) dropnade(); + // Bots cook grenades too. Without this their live grenade would be left in + // the world at the origin, exploding there two seconds later. + if(inhandnade && ((owner==player1) || (owner->type==ENT_BOT))) dropnade(); } void grenades::removebounceent(bounceent *b)