def load_settings(pit: Property): if not pit: SETTINGS.clear() # No pits are permitted.. return SETTINGS.update({ 'use_skybox': srctools.conv_bool(pit['teleport', '0']), 'tele_dest': pit['tele_target', '@goo_targ'], 'tele_ref': pit['tele_ref', '@goo_ref'], 'off_x': srctools.conv_int(pit['off_x', '0']), 'off_y': srctools.conv_int(pit['off_y', '0']), 'skybox': pit['sky_inst', ''], 'skybox_ceil': pit['sky_inst_ceil', ''], 'targ': pit['targ_inst', ''], 'blend_light': pit['blend_light', ''] }) for inst_type in ( 'support', 'side', 'corner', 'double', 'triple', 'pillar', ): vals = [ prop.value for prop in pit.find_all(inst_type + '_inst') ] if len(vals) == 0: vals = [""] PIT_INST[inst_type] = vals
def res_camera_setup(res: Property): return { 'cam_off': Vec.from_str(res['CamOff', '']), 'yaw_off': Vec.from_str(res['YawOff', '']), 'pitch_off': Vec.from_str(res['PitchOff', '']), 'io_inst': resolve_inst(res['IO_inst'])[0], 'yaw_inst': resolve_inst(res['yawInst', ''])[0], 'pitch_inst': resolve_inst(res['pitchInst', ''])[0], 'yaw_range': srctools.conv_int(res['YawRange', ''], 90), 'pitch_range': srctools.conv_int(res['YawRange', ''], 90), }
def parse( vmf: VMF, pack: PackList ) -> Tuple[int, Dict[Tuple[str, str, int], VacObject], Dict[str, str], ]: """Parse out the cube objects from the map. The return value is the number of objects, a dict of objects, and the filenames of the script generated for each group. The dict is (group, model, skin) -> object. """ cube_objects: Dict[Tuple[str, str, int], VacObject] = {} vac_objects: Dict[str, List[VacObject]] = defaultdict(list) for i, ent in enumerate(vmf.by_class['comp_vactube_object']): offset = Vec.from_str(ent['origin']) - Vec.from_str(ent['offset']) obj = VacObject( f'obj_{i:x}', ent['group'], ent['model'], ent['cube_model'], offset, srctools.conv_int(ent['weight']), srctools.conv_int(ent['tv_skin']), srctools.conv_int(ent['cube_skin']), srctools.conv_int(ent['skin']), ) vac_objects[obj.group].append(obj) # Convert the ent into a precache ent, stripping the other keyvalues. ent.keys = {'model': ent['model']} make_precache_prop(ent) if obj.model_drop: cube_objects[obj.group, obj.model_drop.replace('\\', '/'), obj.skin_drop, ] = obj # Generate and pack the vactube object scripts. # Each group is the same, so it can be shared among them all. codes = {} for group in sorted(vac_objects): code = [] for i, obj in enumerate(vac_objects[group]): if obj.model_drop: model_code = f'"{obj.model_drop}"' else: model_code = 'null' code.append( f'{obj.id} <- obj("{obj.model_vac}", {obj.skin_vac}, ' f'{model_code}, {obj.weight}, "{obj.offset}", {obj.skin_tv});') codes[group] = pack.inject_vscript('\n'.join(code)) return len(vac_objects), cube_objects, codes
def get_itemconf( name: Union[str, Tuple[str, str]], default: Optional[OptionType], timer_delay: int = None, ) -> Optional[OptionType]: """Get an itemconfig value. The name should be an 'ID:Section', or a tuple of the same. The type of the default sets what value it will be converted to. None returns the string, or None if not present. If set, timer_value is the value used for the timer. """ if name == '': return default try: if isinstance(name, tuple): group_id, wid_id = name else: group_id, wid_id = name.split(':') except ValueError: LOGGER.warning('Invalid item config: {!r}!', name) return default wid_id = wid_id.casefold() if timer_delay is not None: if timer_delay < 3 or timer_delay > 30: wid_id += '_inf' else: wid_id += '_{}'.format(timer_delay) value = ITEM_CONFIG.get_val(group_id, wid_id, '') if not value: return default if isinstance(default, str) or default is None: return value elif isinstance(default, Vec): return Vec.from_str(value, default.x, default.y, default.z) elif isinstance(default, bool): return srctools.conv_bool(value, default) elif isinstance(default, float): return srctools.conv_int(value, default) elif isinstance(default, int): return srctools.conv_int(value, default) else: raise TypeError('Invalid default type "{}"!'.format( type(default).__name__))
def res_add_variant_setup(res: Property) -> object: if res.has_children(): count = srctools.conv_int(res['Number', ''], None) if count: return conditions.weighted_random( count, res['weights', ''], ) else: return None else: count = srctools.conv_int(res.value, None) if count: return list(range(count)) else: return None
def res_random_setup(res: Property) -> object: weight = '' results = [] chance = 100 seed = 'b' for prop in res: if prop.name == 'chance': # Allow ending with '%' sign chance = srctools.conv_int( prop.value.rstrip('%'), chance, ) elif prop.name == 'weights': weight = prop.value elif prop.name == 'seed': seed = 'b' + prop.value else: results.append(prop) if not results: return None # Invalid! weight = conditions.weighted_random(len(results), weight) # We also need to execute result setups on all child properties! for prop in results[:]: if prop.name == 'group': for sub_prop in list(prop): Condition.setup_result(prop.value, sub_prop) else: Condition.setup_result(results, prop) return seed, chance, weight, results
def res_add_output_setup(res: Property): output = res['output'] input_name = res['input'] inst_in = res['inst_in', ''] inst_out = res['inst_out', ''] targ = res['target'] only_once = srctools.conv_bool(res['only_once', None]) times = 1 if only_once else srctools.conv_int(res['times', None], -1) delay = res['delay', '0.0'] parm = res['parm', ''] if output.startswith('<') and output.endswith('>'): out_id, out_type = output.strip('<>').split(':', 1) out_id = out_id.casefold() out_type = out_type.strip().casefold() else: out_id, out_type = output, 'const' return ( out_type, out_id, targ, input_name, parm, delay, times, inst_in, inst_out, )
def test_conv_int(): for string, result in ints: assert srctools.conv_int(string) == result, string # Check that float values fail marker = object() for string, result in floats: if isinstance(string, str): # We don't want to check float-rounding assert srctools.conv_int(string, marker) is marker, repr(string) # Check non-integers return the default. for string in non_ints: assert srctools.conv_int(string) == 0 for default in def_vals: # Check all default values pass through unchanged assert srctools.conv_int(string, default) is default, repr(string)
def res_add_variant_setup(res: Property): if res.has_children(): count = srctools.conv_int(res['Number', ''], None) if count: return conditions.weighted_random( count, res['weights', ''], ) else: return None else: count = srctools.conv_int(res.value, None) if count: return list(range(count)) else: return None
def res_random_setup(res: Property): weight = '' results = [] chance = 100 seed = '' for prop in res: if prop.name == 'chance': # Allow ending with '%' sign chance = srctools.conv_int( prop.value.rstrip('%'), chance, ) elif prop.name == 'weights': weight = prop.value elif prop.name == 'seed': seed = prop.value else: results.append(prop) if not results: return None # Invalid! weight = conditions.weighted_random(len(results), weight) # We also need to execute result setups on all child properties! for prop in results[:]: if prop.name == 'group': for sub_prop in prop.value[:]: Condition.setup_result(prop.value, sub_prop) else: Condition.setup_result(results, prop) return seed, chance, weight, results
def read_ent_data(self) -> VMF: """Parse in entity data. This returns a VMF object, with entities mirroring that in the BSP. No brushes are read. """ ent_data = self.get_lump(BSP_LUMPS.ENTITIES) vmf = VMF() cur_ent = None # None when between brackets. seen_spawn = False # The first entity is worldspawn. # This code performs the same thing as property_parser, but simpler # since there's no nesting, comments, or whitespace, except between # key and value. We also operate directly on the (ASCII) binary. for line in ent_data.splitlines(): if line == b'{': if cur_ent is not None: raise ValueError( '2 levels of nesting after {} ents'.format( len(vmf.entities))) if not seen_spawn: cur_ent = vmf.spawn seen_spawn = True else: cur_ent = Entity(vmf) elif line == b'}': if cur_ent is None: raise ValueError( 'Too many closing brackets after {} ents'.format( len(vmf.entities))) if cur_ent is vmf.spawn: if cur_ent['classname'] != 'worldspawn': raise ValueError('No worldspawn entity!') else: # The spawn ent is stored in the attribute, not in the ent # list. vmf.add_ent(cur_ent) cur_ent = None elif line == b'\x00': # Null byte at end of lump. if cur_ent is not None: raise ValueError("Last entity didn't end!") return vmf else: # Line is of the form <"key" "val"> key, value = line.split(b'" "') decoded_key = key[1:].decode('ascii') decoded_val = value[:-1].decode('ascii') if 27 in value: # All outputs use the comma_sep, so we can ID them. cur_ent.add_out( Output.parse(Property(decoded_key, decoded_val))) else: # Normal keyvalue. cur_ent[decoded_key] = decoded_val # This keyvalue needs to be stored in the VMF object too. # The one in the entity is ignored. vmf.map_ver = conv_int(vmf.spawn['mapversion'], vmf.map_ver) return vmf
def trigger_brush_input_filters(ctx: Context) -> None: """Copy spawnflags on top of the keyvalue. This way you get checkboxes you can easily control. """ for ent in ctx.vmf.by_class['trigger_brush']: if conv_int(ent['spawnflags']): ent['InputFilter'] = ent['spawnflags']
def res_random(coll: collisions.Collisions, res: Property) -> conditions.ResultCallable: """Randomly choose one of the sub-results to execute. The `chance` value defines the percentage chance for any result to be chosen. `weights` defines the weighting for each result. Both are comma-separated, matching up with the results following. Wrap a set of results in a `group` property block to treat them as a single result to be executed in order. """ weight_str = '' results = [] chance = 100 seed = '' for prop in res: if prop.name == 'chance': # Allow ending with '%' sign chance = srctools.conv_int( prop.value.rstrip('%'), chance, ) elif prop.name == 'weights': weight_str = prop.value elif prop.name == 'seed': seed = 'b' + prop.value else: results.append(prop) if not results: # Does nothing return lambda e: None weights_list = rand.parse_weights(len(results), weight_str) # Note: We can't delete 'global' results, instead replace by 'dummy' # results that don't execute. # Otherwise the chances would be messed up. def apply_random(inst: Entity) -> None: """Pick a random result and run it.""" rng = rand.seed(b'rand_res', inst, seed) if rng.randrange(100) > chance: return ind = rng.choice(weights_list) choice = results[ind] if choice.name == 'nop': pass elif choice.name == 'group': for sub_res in choice: if Condition.test_result(coll, inst, sub_res) is RES_EXHAUSTED: sub_res.name = 'nop' sub_res.value = '' else: if Condition.test_result(coll, inst, choice) is RES_EXHAUSTED: choice.name = 'nop' choice.value = '' return apply_random
def res_vactube_setup(res: Property): group = res['group', 'DEFAULT_GROUP'] if group not in VAC_CONFIGS: # Store our values in the CONFIGS dictionary config, inst_configs = VAC_CONFIGS[group] = {}, {} else: # Grab the already-filled values, and add to them config, inst_configs = VAC_CONFIGS[group] for block in res.find_all("Instance"): # Configuration info for each instance set.. conf = { # The three sizes of corner instance ('corner', 1): block['corner_small_inst', ''], ('corner', 2): block['corner_medium_inst', ''], ('corner', 3): block['corner_large_inst', ''], ('corner_temp', 1): block['temp_corner_small', ''], ('corner_temp', 2): block['temp_corner_medium', ''], ('corner_temp', 3): block['temp_corner_large', ''], # Straight instances connected to the next part 'straight': block['straight_inst', ''], # Supports attach to the 4 sides of the straight part, # if there's a brush there. 'support': block['support_inst', ''], 'is_tsection': srctools.conv_bool(block['is_tsection', '0']), ('entry', 'wall'): block['entry_inst'], ('entry', 'floor'): block['entry_floor_inst'], ('entry', 'ceiling'): block['entry_ceil_inst'], 'exit': block['exit_inst'], } for prop in block.find_all("File"): try: size, file = prop.value.split(":", 1) except ValueError: size = 1 file = prop.value for inst in instanceLocs.resolve(file): inst_configs[inst] = conf, srctools.conv_int(size, 1) return group
def update_disp(var_name: str, var_index: str, operation: str) -> None: """Whenever the string changes, update the displayed text.""" seconds = conv_int(var.get(), -1) if min_value <= seconds <= max_value: disp_var.set('{}:{:02}'.format(seconds // 60, seconds % 60)) else: LOGGER.warning('Bad timer value "{}" for "{}"!', var.get(), conf['id']) # Recurse, with a known safe value. var.set(values[0])
def move_rope(ent: Entity): """Implement move_rope and keyframe_rope resources.""" old_shader_type = conv_int(ent['RopeShader']) if old_shader_type == 0: yield 'materials/cable/cable.vmt' elif old_shader_type == 1: yield 'materials/cable/rope.vmt' else: yield 'materials/cable/chain.vmt' yield 'materials/cable/rope_shadowdepth.vmt'
def move_rope(pack: PackList, ent: Entity) -> None: """Implement move_rope and keyframe_rope resources.""" old_shader_type = conv_int(ent['RopeShader']) if old_shader_type == 0: pack.pack_file('materials/cable/cable.vmt', FileType.MATERIAL) elif old_shader_type == 1: pack.pack_file('materials/cable/rope.vmt', FileType.MATERIAL) else: pack.pack_file('materials/cable/chain.vmt', FileType.MATERIAL) pack.pack_file('materials/cable/rope_shadowdepth.vmt', FileType.MATERIAL)
def force_paintinmap(ctx: Context): """If paint entities are present, set paint in map to true.""" # Already set, don't bother confirming. if conv_bool(ctx.vmf.spawn['paintinmap']): return if needs_paint(ctx.vmf): ctx.vmf.spawn['paintinmap'] = '1' # Ensure we have some blobs. if conv_int(ctx.vmf.spawn['maxblobcount']) == 0: ctx.vmf.spawn['maxblobcount'] = '250'
def res_translate_inst(inst: Entity, res: Property): """Translate the instance locally by the given amount. The special values <piston>, <piston_bottom> and <piston_top> can be used to offset it based on the starting position, bottom or top position of a piston platform. """ folded_val = res.value.casefold() if folded_val == '<piston>': folded_val = ('<piston_top>' if srctools.conv_bool( inst.fixup['$start_up']) else '<piston_bottom>') if folded_val == '<piston_top>': val = Vec(z=128 * srctools.conv_int(inst.fixup['$top_level', '1'], 1)) elif folded_val == '<piston_bottom>': val = Vec(z=128 * srctools.conv_int(inst.fixup['$bottom_level', '0'], 0)) else: val = Vec.from_str(res.value) offset = val.rotate_by_str(inst['angles']) inst['origin'] = (offset + Vec.from_str(inst['origin'])).join(' ')
def flag_random(inst: Entity, res: Property) -> bool: """Randomly is either true or false.""" if res.has_children(): chance = res['chance', '100'] seed = 'a' + res['seed', ''] else: chance = res.value seed = 'a' # Allow ending with '%' sign chance = srctools.conv_int(chance.rstrip('%'), 100) set_random_seed(inst, seed) return random.randrange(100) < chance
def parse(cls, data: ParseData) -> 'QuotePack': """Parse a voice line definition.""" selitem_data = SelitemData.parse(data.info) chars = { char.strip() for char in data.info['characters', ''].split(',') if char.strip() } # For Cave Johnson voicelines, this indicates what skin to use on the # portrait. port_skin = srctools.conv_int(data.info['caveSkin', None], None) try: monitor_data = data.info.find_key('monitor') except NoKeyError: mon_studio = mon_cam_loc = None mon_interrupt = mon_cam_pitch = mon_cam_yaw = 0 mon_studio_actor = '' turret_hate = False else: mon_studio = monitor_data['studio'] mon_studio_actor = monitor_data['studio_actor', ''] mon_interrupt = monitor_data.float('interrupt_chance', 0) mon_cam_loc = monitor_data.vec('Cam_loc') mon_cam_pitch, mon_cam_yaw, _ = monitor_data.vec('Cam_angles') turret_hate = monitor_data.bool('TurretShoot') config = get_config( data.info, data.fsys, 'voice', pak_id=data.pak_id, prop_name='file', ) return cls( data.id, selitem_data, config, chars=chars, skin=port_skin, studio=mon_studio, studio_actor=mon_studio_actor, interrupt=mon_interrupt, cam_loc=mon_cam_loc, cam_pitch=mon_cam_pitch, cam_yaw=mon_cam_yaw, turret_hate=turret_hate, )
def res_translate_inst(inst: Entity, res: Property): """Translate the instance locally by the given amount. The special values <piston>, <piston_bottom> and <piston_top> can be used to offset it based on the starting position, bottom or top position of a piston platform. """ folded_val = res.value.casefold() if folded_val == '<piston>': folded_val = ( '<piston_top>' if srctools.conv_bool(inst.fixup['$start_up']) else '<piston_bottom>' ) if folded_val == '<piston_top>': val = Vec(z=128 * srctools.conv_int(inst.fixup['$top_level', '1'], 1)) elif folded_val == '<piston_bottom>': val = Vec(z=128 * srctools.conv_int(inst.fixup['$bottom_level', '0'], 0)) else: val = Vec.from_str(res.value) offset = val.rotate_by_str(inst['angles']) inst['origin'] = (offset + Vec.from_str(inst['origin'])).join(' ')
def parse(cls, gm_id, config: ConfigFile): steam_id = config.get_val(gm_id, 'SteamID', '<none>') if not steam_id.isdigit(): raise ValueError('Game {} has invalid Steam ID: {}'.format( gm_id, steam_id)) folder = config.get_val(gm_id, 'Dir', '') if not folder: raise ValueError('Game {} has no folder!'.format(gm_id)) mod_times = {} for name, value in config.items(gm_id): if name.startswith('pack_mod_'): mod_times[name[9:].casefold()] = srctools.conv_int(value) return cls(gm_id, steam_id, folder, mod_times)
def flag_random(res: Property) -> Callable[[Entity], bool]: """Randomly is either true or false.""" if res.has_children(): chance = res['chance', '100'] seed = res['seed', ''] else: chance = res.value seed = '' # Allow ending with '%' sign chance = srctools.conv_int(chance.rstrip('%'), 100) def rand_func(inst: Entity) -> bool: """Apply the random chance.""" return rand.seed(b'rand_flag', inst, seed).randrange(100) < chance return rand_func
def res_rand_inst_shift_setup(res: Property): min_x = srctools.conv_int(res['min_x', '0']) max_x = srctools.conv_int(res['max_x', '0']) min_y = srctools.conv_int(res['min_y', '0']) max_y = srctools.conv_int(res['max_y', '0']) min_z = srctools.conv_int(res['min_z', '0']) max_z = srctools.conv_int(res['max_z', '0']) return ( min_x, max_x, min_y, max_y, min_z, max_z, )
def flag_random(inst: Entity, res: Property): """Randomly is either true or false.""" if res.has_children(): chance = res['chance', '100'] seed = res['seed', ''] else: chance = res.value seed = '' # Allow ending with '%' sign chance = srctools.conv_int(chance.rstrip('%'), 100) random.seed('random_chance_{}:{}_{}_{}'.format( seed, inst['targetname', ''], inst['origin'], inst['angles'], )) return random.randrange(100) < chance
def parse(cls, gm_id: str, config: ConfigFile) -> 'Game': """Parse out the given game ID from the config file.""" steam_id = config.get_val(gm_id, 'SteamID', '<none>') if not steam_id.isdigit(): raise ValueError(f'Game {gm_id} has invalid Steam ID: {steam_id}') folder = config.get_val(gm_id, 'Dir', '') if not folder: raise ValueError(f'Game {gm_id} has no folder!') if not os.path.exists(folder): raise ValueError( f'Folder {folder} does not exist for game {gm_id}!') mod_times = {} for name, value in config.items(gm_id): if name.startswith('pack_mod_'): mod_times[name[9:].casefold()] = srctools.conv_int(value) return cls(gm_id, steam_id, folder, mod_times)
def func_breakable_surf(ent: Entity): """Additional materials required for func_breakable_surf""" yield 'models/brokenglass_piece.mdl' surf_type = conv_int(ent['surfacetype']) if surf_type == 1: # Tile yield from ( 'models/brokentile/tilebroken_03a.mdl', 'models/brokentile/tilebroken_03b.mdl', 'models/brokentile/tilebroken_03c.mdl', 'models/brokentile/tilebroken_03d.mdl', 'models/brokentile/tilebroken_02a.mdl', 'models/brokentile/tilebroken_02b.mdl', 'models/brokentile/tilebroken_02c.mdl', 'models/brokentile/tilebroken_02d.mdl', 'models/brokentile/tilebroken_01a.mdl', 'models/brokentile/tilebroken_01b.mdl', 'models/brokentile/tilebroken_01c.mdl', 'models/brokentile/tilebroken_01d.mdl', ) elif surf_type == 0: # Glass yield from ( 'models/brokenglass/glassbroken_solid.mdl', 'models/brokenglass/glassbroken_01a.mdl', 'models/brokenglass/glassbroken_01b.mdl', 'models/brokenglass/glassbroken_01c.mdl', 'models/brokenglass/glassbroken_01d.mdl', 'models/brokenglass/glassbroken_02a.mdl', 'models/brokenglass/glassbroken_02b.mdl', 'models/brokenglass/glassbroken_02c.mdl', 'models/brokenglass/glassbroken_02d.mdl', 'models/brokenglass/glassbroken_03a.mdl', 'models/brokenglass/glassbroken_03b.mdl', 'models/brokenglass/glassbroken_03c.mdl', 'models/brokenglass/glassbroken_03d.mdl', )
def load_connectionpoint(item: Item, ent: Entity) -> None: """Allow more conveniently defining connectionpoints.""" origin = Vec.from_str(ent['origin']) angles = Angle.from_str(ent['angles']) if round(angles.pitch) != 0.0 or round(angles.roll) != 0.0: LOGGER.warning( "Connection Point at {} is not flat on the floor, PeTI doesn't allow this.", origin, ) return try: side = ConnSide.from_yaw(round(angles.yaw)) except ValueError: LOGGER.warning( "Connection Point at {} must point in a cardinal direction, not {}!", origin, angles, ) return orient = Matrix.from_yaw(round(angles.yaw)) center = (origin - (-56, 56, 0)) / 16 center.z = 0 center.y = -center.y try: offset = SKIN_TO_CONN_OFFSETS[ent['skin']] @ orient except KeyError: LOGGER.warning('Connection Point at {} has invalid skin "{}"!', origin) return ant_pos = Coord(round(center.x + offset.x), round(center.y - offset.y), 0) sign_pos = Coord(round(center.x - offset.x), round(center.y + offset.y), 0) group_str = ent['group_id'] item.antline_points[side].append( AntlinePoint(ant_pos, sign_pos, conv_int(ent['priority']), int(group_str) if group_str.strip() else None))
def needs_paint(vmf: VMF) -> bool: """Check if we have paint.""" for ent_cls in [ 'prop_paint_bomb', 'paint_sphere', 'weapon_paintgun', # Not in retail but someone might add it. ]: if vmf.by_class[ent_cls]: return True for ent in vmf.by_class['info_paint_sprayer']: # Special case, this makes sprayers only render visually, which # works even without the value set. if not conv_bool(ent['DrawOnly']): return True for ent_cls in [ 'prop_weighted_cube', 'prop_physics_paintable', ]: for ent in vmf.by_class[ent_cls]: # If the cube is bouncy, enable paint. if conv_int(ent['paintpower', '4'], 4) != 4: return True
def res_add_overlay_inst(inst: Entity, res: Property): """Add another instance on top of this one. Values: File: The filename. Fixup Style: The Fixup style for the instance. '0' (default) is Prefix, '1' is Suffix, and '2' is None. Copy_Fixup: If true, all the $replace values from the original instance will be copied over. move_outputs: If true, outputs will be moved to this instance. offset: The offset (relative to the base) that the instance will be placed. Can be set to '<piston_top>' and '<piston_bottom>' to offset based on the configuration. '<piston_start>' will set it to the starting position, and '<piston_end>' will set it to the ending position. of piston platform handles. angles: If set, overrides the base instance angles. This does not affect the offset property. fixup/localfixup: Keyvalues in this block will be copied to the overlay entity. If the value starts with $, the variable will be copied over. If this is present, copy_fixup will be disabled. """ angle = res["angles", inst["angles", "0 0 0"]] overlay_inst = vbsp.VMF.create_ent( classname="func_instance", targetname=inst["targetname", ""], file=resolve_inst(res["file", ""])[0], angles=angle, origin=inst["origin"], fixup_style=res["fixup_style", "0"], ) # Don't run if the fixup block exists.. if srctools.conv_bool(res["copy_fixup", "1"]): if "fixup" not in res and "localfixup" not in res: # Copy the fixup values across from the original instance for fixup, value in inst.fixup.items(): overlay_inst.fixup[fixup] = value conditions.set_ent_keys(overlay_inst.fixup, inst, res, "fixup") if res.bool("move_outputs", False): overlay_inst.outputs = inst.outputs inst.outputs = [] if "offset" in res: folded_off = res["offset"].casefold() # Offset the overlay by the given distance # Some special placeholder values: if folded_off == "<piston_start>": if srctools.conv_bool(inst.fixup["$start_up", ""]): folded_off = "<piston_top>" else: folded_off = "<piston_bottom>" elif folded_off == "<piston_end>": if srctools.conv_bool(inst.fixup["$start_up", ""]): folded_off = "<piston_bottom>" else: folded_off = "<piston_top>" if folded_off == "<piston_bottom>": offset = Vec(z=srctools.conv_int(inst.fixup["$bottom_level"]) * 128) elif folded_off == "<piston_top>": offset = Vec(z=srctools.conv_int(inst.fixup["$top_level"], 1) * 128) else: # Regular vector offset = Vec.from_str(conditions.resolve_value(inst, res["offset"])) offset.rotate_by_str(inst["angles", "0 0 0"]) overlay_inst["origin"] = (offset + Vec.from_str(inst["origin"])).join(" ") return overlay_inst
def read_ent_data(self) -> VMF: """Parse in entity data. This returns a VMF object, with entities mirroring that in the BSP. No brushes are read. """ ent_data = self.get_lump(BSP_LUMPS.ENTITIES) vmf = VMF() cur_ent = None # None when between brackets. seen_spawn = False # The first entity is worldspawn. # This code performs the same thing as property_parser, but simpler # since there's no nesting, comments, or whitespace, except between # key and value. We also operate directly on the (ASCII) binary. for line in ent_data.splitlines(): if line == b'{': if cur_ent is not None: raise ValueError( '2 levels of nesting after {} ents'.format( len(vmf.entities))) if not seen_spawn: cur_ent = vmf.spawn seen_spawn = True else: cur_ent = Entity(vmf) continue elif line == b'}': if cur_ent is None: raise ValueError(f'Too many closing brackets after' f' {len(vmf.entities)} ents!') if cur_ent is vmf.spawn: if cur_ent['classname'] != 'worldspawn': raise ValueError('No worldspawn entity!') else: # The spawn ent is stored in the attribute, not in the ent # list. vmf.add_ent(cur_ent) cur_ent = None continue elif line == b'\x00': # Null byte at end of lump. if cur_ent is not None: raise ValueError("Last entity didn't end!") return vmf if cur_ent is None: raise ValueError("Keyvalue outside brackets!") # Line is of the form <"key" "val"> key, value = line.split(b'" "') decoded_key = key[1:].decode('ascii') decoded_value = value[:-1].decode('ascii') # Now, we need to figure out if this is a keyvalue, # or connection. # If we're L4D+, this is easy - they use 0x1D as separator. # Before, it's a comma which is common in keyvalues. # Assume it's an output if it has exactly 4 commas, and the last two # successfully parse as numbers. if 27 in value: # All outputs use the comma_sep, so we can ID them. cur_ent.add_out( Output.parse(Property(decoded_key, decoded_value))) elif value.count(b',') == 4: try: cur_ent.add_out( Output.parse(Property(decoded_key, decoded_value))) except ValueError: cur_ent[decoded_key] = decoded_value else: # Normal keyvalue. cur_ent[decoded_key] = decoded_value # This keyvalue needs to be stored in the VMF object too. # The one in the entity is ignored. vmf.map_ver = conv_int(vmf.spawn['mapversion'], vmf.map_ver) return vmf
def res_make_tag_fizzler(vmf: VMF, inst: Entity, res: Property): """Add an Aperture Tag Paint Gun activation fizzler. These fizzlers are created via signs, and work very specially. MUST be priority -100 so it runs before fizzlers! """ import vbsp if vbsp_options.get(str, 'game_id') != utils.STEAM_IDS['TAG']: # Abort - TAG fizzlers shouldn't appear in any other game! inst.remove() return fizz_base = fizz_name = None # Look for the fizzler instance we want to replace for targetname in inst.output_targets(): if targetname in tag_fizzlers: fizz_name = targetname fizz_base = tag_fizzlers[targetname] del tag_fizzlers[targetname] # Don't let other signs mod this one! continue else: # It's an indicator toggle, remove it and the antline to clean up. LOGGER.warning('Toggle: {}', targetname) for ent in vmf.by_target[targetname]: remove_ant_toggle(ent) inst.outputs.clear() # Remove the outptuts now, they're not valid anyway. if fizz_base is None: # No fizzler - remove this sign inst.remove() return # The distance from origin the double signs are seperated by. sign_offset = res.int('signoffset', 16) sign_loc = ( # The actual location of the sign - on the wall Vec.from_str(inst['origin']) + Vec(0, 0, -64).rotate_by_str(inst['angles']) ) # Now deal with the visual aspect: # Blue signs should be on top. blue_enabled = inst.fixup.bool('$start_enabled') oran_enabled = inst.fixup.bool('$start_reversed') # If True, single-color signs will also turn off the other color. # This also means we always show both signs. # If both are enabled or disabled, this has no effect. disable_other = ( not inst.fixup.bool('$disable_autorespawn', True) and blue_enabled != oran_enabled ) # Delete fixups now, they aren't useful. inst.fixup.clear() if not blue_enabled and not oran_enabled: # Hide the sign in this case! inst.remove() inst_angle = srctools.parse_vec_str(inst['angles']) inst_normal = Vec(0, 0, 1).rotate(*inst_angle) loc = Vec.from_str(inst['origin']) if disable_other or (blue_enabled and oran_enabled): inst['file'] = res['frame_double'] # On a wall, and pointing vertically if inst_normal.z == 0 and Vec(y=1).rotate(*inst_angle).z: # They're vertical, make sure blue's on top! blue_loc = Vec(loc.x, loc.y, loc.z + sign_offset) oran_loc = Vec(loc.x, loc.y, loc.z - sign_offset) # If orange is enabled, with two frames put that on top # instead since it's more important if disable_other and oran_enabled: blue_loc, oran_loc = oran_loc, blue_loc else: offset = Vec(0, sign_offset, 0).rotate(*inst_angle) blue_loc = loc + offset oran_loc = loc - offset else: inst['file'] = res['frame_single'] # They're always centered blue_loc = loc oran_loc = loc if inst_normal.z != 0: # If on floors/ceilings, rotate to point at the fizzler! sign_floor_loc = sign_loc.copy() sign_floor_loc.z = 0 # We don't care about z-positions. # Grab the data saved earlier in res_find_potential_tag_fizzlers() axis, side_min, side_max, normal = tag_fizzler_locs[fizz_name] # The Z-axis fizzler (horizontal) must be treated differently. if axis == 'z': # For z-axis, just compare to the center point. # The values are really x, y, z, not what they're named. sign_dir = sign_floor_loc - (side_min, side_max, normal) else: # For the other two, we compare to the line, # or compare to the closest side (in line with the fizz) other_axis = 'x' if axis == 'y' else 'y' if abs(sign_floor_loc[other_axis] - normal) < 32: # Compare to the closest side. Use ** to swap x/y arguments # appropriately. The closest side is the one with the # smallest magnitude. vmf.create_ent( classname='info_null', targetname=inst['targetname'] + '_min', origin=sign_floor_loc - Vec(**{ axis: side_min, other_axis: normal, }), ) vmf.create_ent( classname='info_null', targetname=inst['targetname'] + '_max', origin=sign_floor_loc - Vec(**{ axis: side_max, other_axis: normal, }), ) sign_dir = min( sign_floor_loc - Vec(**{ axis: side_min, other_axis: normal, }), sign_floor_loc - Vec(**{ axis: side_max, other_axis: normal, }), key=Vec.mag, ) else: # Align just based on whether we're in front or behind. sign_dir = Vec() sign_dir[other_axis] = sign_floor_loc[other_axis] - normal sign_angle = math.degrees( math.atan2(sign_dir.y, sign_dir.x) ) # Round to nearest 90 degrees # Add 45 so the switchover point is at the diagonals sign_angle = (sign_angle + 45) // 90 * 90 # Rotate to fit the instances - south is down sign_angle = int(sign_angle + 90) % 360 if inst_normal.z > 0: sign_angle = '0 {} 0'.format(sign_angle) elif inst_normal.z < 0: # Flip upside-down for ceilings sign_angle = '0 {} 180'.format(sign_angle) else: # On a wall, face upright sign_angle = PETI_INST_ANGLE[inst_normal.as_tuple()] # If disable_other, we show off signs. Otherwise we don't use that sign. blue_sign = 'blue_sign' if blue_enabled else 'blue_off_sign' if disable_other else None oran_sign = 'oran_sign' if oran_enabled else 'oran_off_sign' if disable_other else None if blue_sign: vmf.create_ent( classname='func_instance', file=res[blue_sign, ''], targetname=inst['targetname'], angles=sign_angle, origin=blue_loc.join(' '), ) if oran_sign: vmf.create_ent( classname='func_instance', file=res[oran_sign, ''], targetname=inst['targetname'], angles=sign_angle, origin=oran_loc.join(' '), ) # Now modify the fizzler... fizz_brushes = list( vmf.by_class['trigger_portal_cleanser'] & vmf.by_target[fizz_name + '_brush'] ) if 'base_inst' in res: fizz_base['file'] = resolve_inst(res['base_inst'])[0] fizz_base.outputs.clear() # Remove outputs, otherwise they break # branch_toggle entities # Subtract the sign from the list of connections, but don't go below # zero fizz_base.fixup['$connectioncount'] = str(max( 0, srctools.conv_int(fizz_base.fixup['$connectioncount', ''], 0) - 1 )) if 'model_inst' in res: model_inst = resolve_inst(res['model_inst'])[0] for mdl_inst in vmf.by_class['func_instance']: if mdl_inst['targetname', ''].startswith(fizz_name + '_model'): mdl_inst['file'] = model_inst # Find the direction the fizzler front/back points - z=floor fizz # Signs will associate with the given side! bbox_min, bbox_max = fizz_brushes[0].get_bbox() for axis, val in zip('xyz', bbox_max-bbox_min): if val == 2: fizz_axis = axis sign_center = (bbox_min[axis] + bbox_max[axis]) / 2 break else: # A fizzler that's not 128*x*2? raise Exception('Invalid fizzler brush ({})!'.format(fizz_name)) # Figure out what the sides will set values to... pos_blue = False pos_oran = False neg_blue = False neg_oran = False if sign_loc[fizz_axis] < sign_center: pos_blue = blue_enabled pos_oran = oran_enabled else: neg_blue = blue_enabled neg_oran = oran_enabled fizz_off_tex = { 'left': res['off_left'], 'center': res['off_center'], 'right': res['off_right'], 'short': res['off_short'], } fizz_on_tex = { 'left': res['on_left'], 'center': res['on_center'], 'right': res['on_right'], 'short': res['on_short'], } # If it activates the paint gun, use different textures if pos_blue or pos_oran: pos_tex = fizz_on_tex else: pos_tex = fizz_off_tex if neg_blue or neg_oran: neg_tex = fizz_on_tex else: neg_tex = fizz_off_tex if vbsp.GAME_MODE == 'COOP': # We need ATLAS-specific triggers pos_trig = vmf.create_ent( classname='trigger_playerteam', ) neg_trig = vmf.create_ent( classname='trigger_playerteam', ) output = 'OnStartTouchBluePlayer' else: pos_trig = vmf.create_ent( classname='trigger_multiple', ) neg_trig = vmf.create_ent( classname='trigger_multiple', spawnflags='1', ) output = 'OnStartTouch' pos_trig['origin'] = neg_trig['origin'] = fizz_base['origin'] pos_trig['spawnflags'] = neg_trig['spawnflags'] = '1' # Clients Only pos_trig['targetname'] = fizz_name + '-trig_pos' neg_trig['targetname'] = fizz_name + '-trig_neg' pos_trig.outputs = [ Output( output, fizz_name + '-trig_neg', 'Enable', ), Output( output, fizz_name + '-trig_pos', 'Disable', ), ] neg_trig.outputs = [ Output( output, fizz_name + '-trig_pos', 'Enable', ), Output( output, fizz_name + '-trig_neg', 'Disable', ), ] voice_attr = vbsp.settings['has_attr'] if blue_enabled or disable_other: # If this is blue/oran only, don't affect the other color neg_trig.outputs.append(Output( output, '@BlueIsEnabled', 'SetValue', param=srctools.bool_as_int(neg_blue), )) pos_trig.outputs.append(Output( output, '@BlueIsEnabled', 'SetValue', param=srctools.bool_as_int(pos_blue), )) if blue_enabled: # Add voice attributes - we have the gun and gel! voice_attr['bluegelgun'] = True voice_attr['bluegel'] = True voice_attr['bouncegun'] = True voice_attr['bouncegel'] = True if oran_enabled or disable_other: neg_trig.outputs.append(Output( output, '@OrangeIsEnabled', 'SetValue', param=srctools.bool_as_int(neg_oran), )) pos_trig.outputs.append(Output( output, '@OrangeIsEnabled', 'SetValue', param=srctools.bool_as_int(pos_oran), )) if oran_enabled: voice_attr['orangegelgun'] = True voice_attr['orangegel'] = True voice_attr['speedgelgun'] = True voice_attr['speedgel'] = True if not oran_enabled and not blue_enabled: # If both are disabled, we must shutdown the gun when touching # either side - use neg_trig for that purpose! # We want to get rid of pos_trig to save ents vmf.remove_ent(pos_trig) neg_trig['targetname'] = fizz_name + '-trig' neg_trig.outputs.clear() neg_trig.add_out(Output( output, '@BlueIsEnabled', 'SetValue', param='0' )) neg_trig.add_out(Output( output, '@OrangeIsEnabled', 'SetValue', param='0' )) for fizz_brush in fizz_brushes: # portal_cleanser ent, not solid! # Modify fizzler textures bbox_min, bbox_max = fizz_brush.get_bbox() for side in fizz_brush.sides(): norm = side.normal() if norm[fizz_axis] == 0: # Not the front/back: force nodraw # Otherwise the top/bottom will have the odd stripes # which won't match the sides side.mat = 'tools/toolsnodraw' continue if norm[fizz_axis] == 1: side.mat = pos_tex[ vbsp.TEX_FIZZLER[ side.mat.casefold() ] ] else: side.mat = neg_tex[ vbsp.TEX_FIZZLER[ side.mat.casefold() ] ] # The fizzler shouldn't kill cubes fizz_brush['spawnflags'] = '1' fizz_brush.outputs.append(Output( 'OnStartTouch', '@shake_global', 'StartShake', )) fizz_brush.outputs.append(Output( 'OnStartTouch', '@shake_global_sound', 'PlaySound', )) # The triggers are 8 units thick, 24 from the center # (-1 because fizzlers are 2 thick on each side). neg_min, neg_max = Vec(bbox_min), Vec(bbox_max) neg_min[fizz_axis] -= 23 neg_max[fizz_axis] -= 17 pos_min, pos_max = Vec(bbox_min), Vec(bbox_max) pos_min[fizz_axis] += 17 pos_max[fizz_axis] += 23 if blue_enabled or oran_enabled: neg_trig.solids.append( vmf.make_prism( neg_min, neg_max, mat='tools/toolstrigger', ).solid, ) pos_trig.solids.append( vmf.make_prism( pos_min, pos_max, mat='tools/toolstrigger', ).solid, ) else: # If neither enabled, use one trigger neg_trig.solids.append( vmf.make_prism( neg_min, pos_max, mat='tools/toolstrigger', ).solid, )
def vactube_transform(ctx: Context) -> None: """Implements the dynamic Vactube system.""" all_nodes = list(nodes.parse(ctx.vmf)) if not all_nodes: # No vactubes. return LOGGER.info('{} vactube nodes found.', len(all_nodes)) LOGGER.debug('Nodes: {}', all_nodes) if ctx.studiomdl is None: raise ValueError('Vactubes present, but no studioMDL path provided! ' 'Set the path to studiomdl.exe in srctools.vdf.') obj_count, vac_objects, objects_code = objects.parse(ctx.vmf, ctx.pack) groups = set(objects_code) if not obj_count: raise ValueError('Vactube nodes present, but no objects. ' 'You need to add comp_vactube_objects to your map ' 'to define the contents.') LOGGER.info('{} vactube objects found.', obj_count) # Now join all the nodes to each other. # Tubes only have 90 degree bends, so a system should mostly be formed # out of about 6 different normals. So group by that. inputs_by_norm: Dict[Tuple[float, float, float], List[Tuple[Vec, nodes.Node]]] = defaultdict(list) for node in all_nodes: # Spawners have no inputs. if isinstance(node, nodes.Spawner): node.has_input = True else: inputs_by_norm[node.input_norm().as_tuple()].append( (node.vec_point(0.0), node)) norm_inputs = [(Vec(norm), node_lst) for norm, node_lst in inputs_by_norm.items()] sources: List[nodes.Spawner] = [] LOGGER.info('Linking nodes...') for node in all_nodes: # Destroyers (or Droppers) have no inputs. if isinstance(node, nodes.Destroyer): continue for dest_type in node.out_types: node.outputs[dest_type] = find_closest( norm_inputs, node, node.vec_point(1.0, dest_type), node.output_norm(dest_type), ) if isinstance(node, nodes.Spawner): sources.append(node) if node.group not in groups: group_warn = (f'Node {node} uses group "{node.group}", ' 'which has no objects registered!') if '' in groups: # Fall back to ignoring the group, using the default # blank one which is present. LOGGER.warning("{} Using blank group.", group_warn) node.group = "" else: raise ValueError(group_warn) # Run through them again, check to see if any miss inputs. for node in all_nodes: if not node.has_input: raise ValueError('No source found for junction ' f'{node.ent["targetname"]} at ({node.origin})!') LOGGER.info('Generating animations...') all_anims = animations.generate(sources) # Sort the animations by their start and end, so they ideally are consistent. all_anims.sort(key=lambda a: (a.start_node.origin, a.end_node.origin)) anim_mdl_name = Path('maps', ctx.bsp_path.stem, 'vac_anim.mdl') # Now generate the animation model. # First wipe the model. full_loc = ctx.game.path / 'models' / anim_mdl_name for ext in MDL_EXTS: try: full_loc.with_suffix(ext).unlink() except FileNotFoundError: pass with TemporaryDirectory(prefix='vactubes_') as temp_dir: # Make the reference mesh. with open(temp_dir + '/ref.smd', 'wb') as f: Mesh.build_bbox('root', 'demo', Vec(-32, -32, -32), Vec(32, 32, 32)).export(f) with open(temp_dir + '/prop.qc', 'w') as qc_file: qc_file.write(QC_TEMPLATE.format(path=anim_mdl_name)) for i, anim in enumerate(all_anims): anim.name = anim_name = f'anim_{i:03x}' qc_file.write( SEQ_TEMPLATE.format(name=anim_name, fps=animations.FPS)) with open(temp_dir + f'/{anim_name}.smd', 'wb') as f: anim.mesh.export(f) args = [ str(ctx.studiomdl), '-nop4', '-i', # Ignore warnings. '-game', str(ctx.game.path), temp_dir + '/prop.qc', ] LOGGER.info('Compiling vactube animations {}...', args) subprocess.run(args) # Ensure they're all packed. for ext in MDL_EXTS: try: f = full_loc.with_suffix(ext).open('rb') except FileNotFoundError: pass else: with f: ctx.pack.pack_file(Path('models', anim_mdl_name.with_suffix(ext)), data=f.read()) LOGGER.info('Setting up vactube ents...') # Generate the shared template. ctx.vmf.create_ent( 'prop_dynamic', targetname='_vactube_temp_mover', angles='0 270 0', origin='-16384 0 1024', model=str(Path('models', anim_mdl_name)), rendermode=10, solid=0, spawnflags=64 | 256, # Use Hitboxes for Renderbox, collision disabled. ) ctx.vmf.create_ent( 'prop_dynamic_override', # In case you use the physics model. targetname='_vactube_temp_visual', parentname='_vactube_temp_mover,move', origin='-16384 0 1024', model=nodes.CUBE_MODEL, solid=0, spawnflags=64 | 256, # Use Hitboxes for Renderbox, collision disabled. ) ctx.vmf.create_ent( 'point_template', targetname='_vactube_template', template01='_vactube_temp_mover', template02='_vactube_temp_visual', origin='-16384 0 1024', spawnflags='2', # Preserve names, remove originals. ) # Group animations by their start point. anims_by_start: Dict[nodes.Spawner, List[animations.Animation]] = defaultdict(list) for anim in all_anims: anims_by_start[anim.start_node].append(anim) # And create a dict to link droppers to the animation they want. dropper_to_anim: Dict[nodes.Dropper, animations.Animation] = {} for start_node, anims in anims_by_start.items(): spawn_maker = start_node.ent spawn_maker['classname'] = 'env_entity_maker' spawn_maker['entitytemplate'] = '_vactube_template' spawn_maker['angles'] = '0 0 0' orig_name = spawn_maker['targetname'] spawn_maker.make_unique('_vac_maker') spawn_name = spawn_maker['targetname'] if start_node.is_auto: spawn_timer = ctx.vmf.create_ent( 'logic_timer', targetname=spawn_name + '_timer', origin=start_node.origin, startdisabled='0', userandomtime='1', lowerrandombound=start_node.time_min, upperrandombound=start_node.time_max, ).make_unique() spawn_timer.add_out( Output('OnTimer', spawn_name, 'CallScriptFunction', 'make_cube')) ctx.add_io_remap( orig_name, Output('EnableTimer', spawn_timer, 'Enable'), Output('DisableTimer', spawn_timer, 'Disable'), ) ctx.add_io_remap( orig_name, Output('ForceSpawn', spawn_name, 'CallScriptFunction', 'make_cube'), ) # Now, generate the code so the VScript knows about the animations. code = [ f'// Node: {start_node.ent["targetname"]}, {start_node.origin}' ] for anim in anims: target = anim.end_node anim_speed = anim.start_node.speed pass_code = ','.join([ f'Output({time:.2f}, "{node.ent["targetname"]}", ' f'{node.tv_code(anim_speed)})' for time, node in anim.pass_points ]) cube_name = 'null' if isinstance(target, nodes.Dropper): cube_model = target.cube['model'].replace('\\', '/') cube_skin = conv_int(target.cube['skin']) try: cube_name = vac_objects[start_node.group, cube_model, cube_skin].id except KeyError: LOGGER.warning( 'Cube model "{}", skin {} is not a type of cube travelling ' 'in this vactube!\n\n' 'Add a comp_vactube_object entity with this cube model' # Mention groups if they're used, otherwise it's not important. + (f' with the group "{start_node.group}".' if start_node.group else '.'), cube_model, cube_skin, ) continue # Skip this animation so it's not broken. else: dropper_to_anim[target] = anim code.append(f'{anim.name} <- anim("{anim.name}", {anim.duration}, ' f'{cube_name}, [{pass_code}]);') spawn_maker['vscripts'] = ' '.join([ 'srctools/vac_anim.nut', objects_code[start_node.group], ctx.pack.inject_vscript('\n'.join(code)), ]) # Now, go through each dropper and generate their logic. for dropper, anim in dropper_to_anim.items(): # Pick the appropriate output to fire once left the dropper. if dropper.cube['classname'] == 'prop_monster_box': cube_input = 'BecomeMonster' else: cube_input = 'EnablePortalFunnel' ctx.add_io_remap( dropper.ent['targetname'], # Used to dissolve the existing cube when respawning. Output('FireCubeUser1', dropper.cube['targetname'], 'FireUser1'), # Tell the spawn to redirect a cube to us. Output( 'RequestSpawn', anim.start_node.ent['targetname'], 'RunScriptCode', f'{anim.name}.req_spawn = true', ), Output('CubeReleased', '!activator', cube_input), )
def widget_minute_seconds(parent: tk.Frame, var: tk.StringVar, conf: Property) -> tk.Misc: """A widget for specifying times - minutes and seconds. The value is saved as seconds. Max specifies the largest amount. """ max_value = conf.int('max', 60) min_value = conf.int('min', 0) if min_value > max_value: raise ValueError('Bad min and max values!') values = timer_values(min_value, max_value) default_value = conv_int(var.get(), -1) if min_value <= default_value <= max_value: default_text = values[default_value - min_value] else: LOGGER.warning('Bad timer value "{}" for "{}"!', var.get(), conf['id']) default_text = '0:01' var.set('1') disp_var = tk.StringVar() def set_var(): """Set the variable to the current value.""" try: minutes, seconds = disp_var.get().split(':') var.set(int(minutes) * 60 + int(seconds)) except (ValueError, TypeError): pass def validate(reason: str, operation_type: str, cur_value: str, new_char: str, new_value: str): """Validate the values for the text.""" if operation_type == '0' or reason == 'forced': # Deleting or done by the program, allow that always. return True if operation_type == '1': # Disallow non number and colons if new_char not in '0123456789:': return False # Only one colon. if ':' in cur_value and new_char == ':': return False # Don't allow more values if it has more than 2 numbers after # the colon - if there is one, and it's not in the last 3 characters. if ':' in new_value and ':' not in new_value[-3:]: return False if reason == 'focusout': # When leaving focus, apply range limits and set the var. try: minutes, seconds = new_value.split(':') seconds = int(minutes) * 60 + int(seconds) except (ValueError, TypeError): seconds = default_value if seconds < min_value: seconds = min_value if seconds > max_value: seconds = max_value disp_var.set('{}:{:02}'.format(seconds // 60, seconds % 60)) var.set(seconds) return True validate_cmd = parent.register(validate) spinbox = tk.Spinbox( parent, exportselection=False, textvariable=disp_var, command=set_var, wrap=True, values=values, validate='all', # %args substitute the values for the args to validate_cmd. validatecommand=(validate_cmd, '%V', '%d', '%s', '%S', '%P'), ) # We need to set this after, it gets reset to the first one. disp_var.set(default_text) return spinbox
def res_unst_scaffold(res: Property): """The condition to generate Unstationary Scaffolds. This is executed once to modify all instances. """ # The instance types we're modifying if res.value not in SCAFFOLD_CONFIGS: # We've already executed this config group return RES_EXHAUSTED LOGGER.info("Running Scaffold Generator ({})...", res.value) TARG_INST, LINKS = SCAFFOLD_CONFIGS[res.value] del SCAFFOLD_CONFIGS[res.value] # Don't let this run twice instances = {} # Find all the instances we're wanting to change, and map them to # targetnames for ent in vbsp.VMF.by_class["func_instance"]: file = ent["file"].casefold() targ = ent["targetname"] if file not in TARG_INST: continue config = TARG_INST[file] next_inst = set(out.target for out in ent.outputs) # Destroy these outputs, they're useless now! ent.outputs.clear() instances[targ] = {"ent": ent, "conf": config, "next": next_inst, "prev": None} # Now link each instance to its in and outputs for targ, inst in instances.items(): scaff_targs = 0 for ent_targ in inst["next"]: if ent_targ in instances: instances[ent_targ]["prev"] = targ inst["next"] = ent_targ scaff_targs += 1 else: # If it's not a scaffold, it's probably an indicator_toggle. # We want to remove any them as well as the assoicated # antlines! for toggle in vbsp.VMF.by_target[ent_targ]: conditions.remove_ant_toggle(toggle) if scaff_targs > 1: raise Exception("A scaffold item has multiple destinations!") elif scaff_targs == 0: inst["next"] = None # End instance starting_inst = [] # We need to find the start instances, so we can set everything up for inst in instances.values(): if inst["prev"] is None and inst["next"] is None: # Static item! continue elif inst["prev"] is None: starting_inst.append(inst) # We need to make the link entities unique for each scaffold set, # otherwise the AllVar property won't work. group_counter = 0 # Set all the instances and properties for start_inst in starting_inst: group_counter += 1 ent = start_inst["ent"] for vals in LINKS.values(): if vals["all"] is not None: ent.fixup[vals["all"]] = SCAFF_PATTERN.format(name=vals["name"], group=group_counter, index="*") should_reverse = srctools.conv_bool(ent.fixup["$start_reversed"]) # Now set each instance in the chain, including first and last for index, inst in enumerate(scaff_scan(instances, start_inst)): ent, conf = inst["ent"], inst["conf"] orient = "floor" if Vec(0, 0, 1).rotate_by_str(ent["angles"]) == (0, 0, 1) else "wall" # Find the offset used for the logic ents offset = (conf["off_" + orient]).copy() if conf["is_piston"]: # Adjust based on the piston position offset.z += 128 * srctools.conv_int( ent.fixup["$top_level" if ent.fixup["$start_up"] == "1" else "$bottom_level"] ) offset.rotate_by_str(ent["angles"]) offset += Vec.from_str(ent["origin"]) if inst["prev"] is None: link_type = "start" elif inst["next"] is None: link_type = "end" else: link_type = "mid" if orient == "floor" and link_type != "mid" and conf["inst_end"] is not None: # Add an extra instance pointing in the direction # of the connected track. This would be the endcap # model. other_ent = instances[inst["next" if link_type == "start" else "prev"]]["ent"] other_pos = Vec.from_str(other_ent["origin"]) our_pos = Vec.from_str(ent["origin"]) link_dir = other_pos - our_pos link_ang = math.degrees(math.atan2(link_dir.y, link_dir.x)) # Round to nearest 90 degrees # Add 45 so the switchover point is at the diagonals link_ang = (link_ang + 45) // 90 * 90 vbsp.VMF.create_ent( classname="func_instance", targetname=ent["targetname"], file=conf["inst_end"], origin=offset.join(" "), angles="0 {:.0f} 0".format(link_ang), ) # Don't place the offset instance, this replaces that! elif conf["inst_offset"] is not None: # Add an additional rotated entity at the offset. # This is useful for the piston item. vbsp.VMF.create_ent( classname="func_instance", targetname=ent["targetname"], file=conf["inst_offset"], origin=offset.join(" "), angles=ent["angles"], ) logic_inst = vbsp.VMF.create_ent( classname="func_instance", targetname=ent["targetname"], file=conf.get("logic_" + link_type + ("_rev" if should_reverse else ""), ""), origin=offset.join(" "), angles=("0 0 0" if conf["rotate_logic"] else ent["angles"]), ) for key, val in ent.fixup.items(): # Copy over fixup values logic_inst.fixup[key] = val # Add the link-values for linkVar, link in LINKS.items(): logic_inst.fixup[linkVar] = SCAFF_PATTERN.format(name=link["name"], group=group_counter, index=index) if inst["next"] is not None: logic_inst.fixup[link["next"]] = SCAFF_PATTERN.format( name=link["name"], group=group_counter, index=index + 1 ) new_file = conf.get("inst_" + orient, "") if new_file != "": ent["file"] = new_file LOGGER.info("Finished Scaffold generation!") return RES_EXHAUSTED
def res_cutout_tile(res: Property): """Generate random quarter tiles, like in Destroyed or Retro maps. - "MarkerItem" is the instance to look for. - "TileSize" can be "2x2" or "4x4". - rotateMax is the amount of degrees to rotate squarebeam models. Materials: - "squarebeams" is the squarebeams variant to use. - "ceilingwalls" are the sides of the ceiling section. - "floorbase" is the texture under floor sections. - "tile_glue" is used on top of a thinner tile segment. - "clip" is the player_clip texture used over floor segments. (This allows customising the surfaceprop.) - "Floor4x4Black", "Ceil2x2White" and other combinations can be used to override the textures used. """ item = resolve_inst(res['markeritem']) INST_LOCS = {} # Map targetnames -> surface loc CEIL_IO = [] # Pairs of ceil inst corners to cut out. FLOOR_IO = [] # Pairs of floor inst corners to cut out. overlay_ids = {} # When we replace brushes, we need to fix any overlays # on that surface. MATS.clear() floor_edges = [] # Values to pass to add_floor_sides() at the end sign_loc = set(FORCE_LOCATIONS) # If any signage is present in the map, we need to force tiles to # appear at that location! for over in conditions.VMF.by_class['info_overlay']: if ( over['material'].casefold() in FORCE_TILE_MATS and # Only check floor/ceiling overlays over['basisnormal'] in ('0 0 1', '0 0 -1') ): loc = Vec.from_str(over['origin']) # Sometimes (light bridges etc) a sign will be halfway between # tiles, so in that case we need to force 2 tiles. loc_min = (loc - (15, 15, 0)) // 32 * 32 # type: Vec loc_max = (loc + (15, 15, 0)) // 32 * 32 # type: Vec loc_min += (16, 16, 0) loc_max += (16, 16, 0) FORCE_LOCATIONS.add(loc_min.as_tuple()) FORCE_LOCATIONS.add(loc_max.as_tuple()) SETTINGS = { 'floor_chance': srctools.conv_int( res['floorChance', '100'], 100), 'ceil_chance': srctools.conv_int( res['ceilingChance', '100'], 100), 'floor_glue_chance': srctools.conv_int( res['floorGlueChance', '0']), 'ceil_glue_chance': srctools.conv_int( res['ceilingGlueChance', '0']), 'rotate_beams': int(srctools.conv_float( res['rotateMax', '0']) * BEAM_ROT_PRECISION), 'beam_skin': res['squarebeamsSkin', '0'], 'base_is_disp': srctools.conv_bool(res['dispBase', '0']), 'quad_floor': res['FloorSize', '4x4'].casefold() == '2x2', 'quad_ceil': res['CeilingSize', '4x4'].casefold() == '2x2', } random.seed(vbsp.MAP_RAND_SEED + '_CUTOUT_TILE_NOISE') noise = SimplexNoise(period=4 * 40) # 4 tiles/block, 50 blocks max # We want to know the number of neighbouring tile cutouts before # placing tiles - blocks away from the sides generate fewer tiles. floor_neighbours = defaultdict(dict) # all_floors[z][x,y] = count for mat_prop in res['Materials', []]: MATS[mat_prop.name].append(mat_prop.value) if SETTINGS['base_is_disp']: # We want the normal brushes to become nodraw. MATS['floorbase_disp'] = MATS['floorbase'] MATS['floorbase'] = ['tools/toolsnodraw'] # Since this uses random data for initialisation, the alpha and # regular will use slightly different patterns. alpha_noise = SimplexNoise(period=4 * 50) else: alpha_noise = None for key, default in TEX_DEFAULT: if key not in MATS: MATS[key] = [default] # Find our marker ents for inst in conditions.VMF.by_class['func_instance']: # type: VLib.Entity if inst['file'].casefold() not in item: continue targ = inst['targetname'] orient = Vec(0, 0, 1).rotate_by_str(inst['angles', '0 0 0']) # Check the orientation of the marker to figure out what to generate if orient == (0, 0, 1): io_list = FLOOR_IO else: io_list = CEIL_IO # Reuse orient to calculate where the solid face will be. loc = (orient * -64) + Vec.from_str(inst['origin']) INST_LOCS[targ] = loc for out in inst.output_targets(): io_list.append((targ, out)) if not inst.outputs and inst.fixup['$connectioncount'] == '0': # If the item doesn't have any connections, 'connect' # it to itself so we'll generate a 128x128 tile segment. io_list.append((targ, targ)) inst.remove() # Remove the instance itself from the map. for start_floor, end_floor in FLOOR_IO: if end_floor not in INST_LOCS: # Not a marker - remove this and the antline. for toggle in conditions.VMF.by_target[end_floor]: conditions.remove_ant_toggle(toggle) continue box_min = Vec(INST_LOCS[start_floor]) box_min.min(INST_LOCS[end_floor]) box_max = Vec(INST_LOCS[start_floor]) box_max.max(INST_LOCS[end_floor]) if box_min.z != box_max.z: continue # They're not in the same level! z = box_min.z if SETTINGS['rotate_beams']: # We have to generate 1 model per 64x64 block to do rotation... gen_rotated_squarebeams( box_min - (64, 64, 0), box_max + (64, 64, -8), skin=SETTINGS['beam_skin'], max_rot=SETTINGS['rotate_beams'], ) else: # Make the squarebeams props, using big models if possible gen_squarebeams( box_min + (-64, -64, 0), box_max + (64, 64, -8), skin=SETTINGS['beam_skin'] ) # Add a player_clip brush across the whole area conditions.VMF.add_brush(conditions.VMF.make_prism( p1=box_min - (64, 64, FLOOR_DEPTH), p2=box_max + (64, 64, 0), mat=MATS['clip'][0], ).solid) # Add a noportal_volume covering the surface, in case there's # room for a portal. noportal_solid = conditions.VMF.make_prism( # Don't go all the way to the sides, so it doesn't affect wall # brushes. p1=box_min - (63, 63, 9), p2=box_max + (63, 63, 0), mat='tools/toolsinvisible', ).solid noportal_ent = conditions.VMF.create_ent( classname='func_noportal_volume', origin=box_min.join(' '), ) noportal_ent.solids.append(noportal_solid) if SETTINGS['base_is_disp']: # Use displacements for the base instead. make_alpha_base( box_min + (-64, -64, 0), box_max + (64, 64, 0), noise=alpha_noise, ) for x, y in utils.iter_grid( min_x=int(box_min.x), max_x=int(box_max.x) + 1, min_y=int(box_min.y), max_y=int(box_max.y) + 1, stride=128, ): # Build the set of all positions.. floor_neighbours[z][x, y] = -1 # Mark borders we need to fill in, and the angle (for func_instance) # The wall is the face pointing inwards towards the bottom brush, # and the ceil is the ceiling of the block above the bordering grid # points. for x in range(int(box_min.x), int(box_max.x) + 1, 128): # North floor_edges.append(BorderPoints( wall=Vec(x, box_max.y + 64, z - 64), ceil=Vec_tuple(x, box_max.y + 128, z), rot=270, )) # South floor_edges.append(BorderPoints( wall=Vec(x, box_min.y - 64, z - 64), ceil=Vec_tuple(x, box_min.y - 128, z), rot=90, )) for y in range(int(box_min.y), int(box_max.y) + 1, 128): # East floor_edges.append(BorderPoints( wall=Vec(box_max.x + 64, y, z - 64), ceil=Vec_tuple(box_max.x + 128, y, z), rot=180, )) # West floor_edges.append(BorderPoints( wall=Vec(box_min.x - 64, y, z - 64), ceil=Vec_tuple(box_min.x - 128, y, z), rot=0, )) # Now count boundries near tiles, then generate them. # Do it seperately for each z-level: for z, xy_dict in floor_neighbours.items(): # type: float, dict for x, y in xy_dict: # type: float, float # We want to count where there aren't any tiles xy_dict[x, y] = ( ((x - 128, y - 128) not in xy_dict) + ((x - 128, y + 128) not in xy_dict) + ((x + 128, y - 128) not in xy_dict) + ((x + 128, y + 128) not in xy_dict) + ((x - 128, y) not in xy_dict) + ((x + 128, y) not in xy_dict) + ((x, y - 128) not in xy_dict) + ((x, y + 128) not in xy_dict) ) max_x = max_y = 0 weights = {} # Now the counts are all correct, compute the weight to apply # for tiles. # Adding the neighbouring counts will make a 5x5 area needed to set # the center to 0. for (x, y), cur_count in xy_dict.items(): max_x = max(x, max_x) max_y = max(y, max_y) # Orthrogonal is worth 0.2, diagonal is worth 0.1. # Not-present tiles would be 8 - the maximum tile_count = ( 0.8 * cur_count + 0.1 * xy_dict.get((x - 128, y - 128), 8) + 0.1 * xy_dict.get((x - 128, y + 128), 8) + 0.1 * xy_dict.get((x + 128, y - 128), 8) + 0.1 * xy_dict.get((x + 128, y + 128), 8) + 0.2 * xy_dict.get((x - 128, y), 8) + 0.2 * xy_dict.get((x, y - 128), 8) + 0.2 * xy_dict.get((x, y + 128), 8) + 0.2 * xy_dict.get((x + 128, y), 8) ) # The number ranges from 0 (all tiles) to 12.8 (no tiles). # All tiles should still have a small chance to generate tiles. weights[x, y] = min((tile_count + 0.5) / 8, 1) # Share the detail entity among same-height tiles.. detail_ent = conditions.VMF.create_ent( classname='func_detail', ) for x, y in xy_dict: convert_floor( Vec(x, y, z), overlay_ids, MATS, SETTINGS, sign_loc, detail_ent, noise_weight=weights[x, y], noise_func=noise, ) add_floor_sides(floor_edges) conditions.reallocate_overlays(overlay_ids) return conditions.RES_EXHAUSTED
def res_conveyor_belt(inst: Entity, res: Property): """Create a conveyor belt. Options: SegmentInst: Generated at each square. ('track' is the name of the path.) TrackTeleport: Set the track points so they teleport trains to the start. Speed: The fixup or number for the train speed. MotionTrig: If set, a trigger_multiple will be spawned that EnableMotions weighted cubes. The value is the name of the relevant filter. EndOutput: Adds an output to the last track. The value is the same as outputs in VMFs. RotateSegments: If true (default), force segments to face in the direction of movement RailTemplate: A template for the railings. This is made into a non-solid func_brush, combining all sections. """ move_dist = srctools.conv_int(inst.fixup['$travel_distance']) if move_dist <= 2: # There isn't room for a catwalk, so don't bother. inst.remove() return move_dir = Vec(1, 0, 0).rotate_by_str(inst.fixup['$travel_direction']) move_dir.rotate_by_str(inst['angles']) start_offset = srctools.conv_float(inst.fixup['$starting_position'], 0) teleport_to_start = res.bool('TrackTeleport', True) segment_inst_file = res['SegmentInst', ''] rail_template = res['RailTemplate', None] vmf = inst.map if segment_inst_file: segment_inst_file = conditions.resolve_inst(segment_inst_file)[0] track_speed = res['speed', None] start_pos = Vec.from_str(inst['origin']) end_pos = start_pos + move_dist * move_dir if start_offset > 0: # If an oscillating platform, move to the closest side.. offset = start_offset * move_dir # The instance is placed this far along, so move back to the end. start_pos -= offset end_pos -= offset if start_offset > 0.5: # Swap the direction of movement.. start_pos, end_pos = end_pos, start_pos inst['origin'] = start_pos # Find the angle which generates an instance pointing in the direction # of movement, with the same normal. norm = Vec(z=1).rotate_by_str(inst['angles']) for roll in range(0, 360, 90): angles = move_dir.to_angle(roll) if Vec(z=1).rotate(*angles) == norm: break else: raise ValueError( "Can't find angles to give a" ' z={} and x={}!'.format(norm, move_dir) ) if res.bool('rotateSegments', True): inst['angles'] = angles else: angles = Vec.from_str(inst['angles']) # Add the EnableMotion trigger_multiple seen in platform items. # This wakes up cubes when it starts moving. motion_filter = res['motionTrig', None] # Disable on walls, or if the conveyor can't be turned on. if norm != (0, 0, 1) or inst.fixup['$connectioncount'] == '0': motion_filter = None track_name = conditions.local_name(inst, 'segment_{}') rail_temp_solids = [] last_track = None # Place beams at the top, so they don't appear inside wall sections. beam_start = start_pos + 48 * norm # type: Vec beam_end = end_pos + 48 * norm # type: Vec for index, pos in enumerate(beam_start.iter_line(beam_end, stride=128), start=1): track = vmf.create_ent( classname='path_track', targetname=track_name.format(index) + '-track', origin=pos, spawnflags=0, orientationtype=0, # Don't rotate ) if track_speed is not None: track['speed'] = track_speed if last_track: last_track['target'] = track['targetname'] if index == 1 and teleport_to_start: track['spawnflags'] = 16 # Teleport here.. last_track = track # Don't place at the last point - it doesn't teleport correctly, # and would be one too many. if segment_inst_file and pos != end_pos: seg_inst = vmf.create_ent( classname='func_instance', targetname=track_name.format(index), file=segment_inst_file, origin=pos, angles=angles, ) seg_inst.fixup.update(inst.fixup) if rail_template: temp = conditions.import_template( rail_template, pos, angles, force_type=conditions.TEMP_TYPES.world, add_to_map=False, ) rail_temp_solids.extend(temp.world) if rail_temp_solids: vmf.create_ent( classname='func_brush', origin=beam_start, spawnflags=1, # Ignore +USE solidity=1, # Not solid vrad_brush_cast_shadows=1, drawinfastreflection=1, ).solids = rail_temp_solids if teleport_to_start: # Link back to the first track.. last_track['target'] = track_name.format(1) + '-track' # Generate an env_beam pointing from the start to the end of the track. beam_keys = res.find_key('BeamKeys', []) if beam_keys.value: beam = vmf.create_ent(classname='env_beam') # 3 offsets - x = distance from walls, y = side, z = height beam_off = beam_keys.vec('origin', 0, 63, 56) for prop in beam_keys: beam[prop.real_name] = prop.value # Localise the targetname so it can be triggered.. beam['LightningStart'] = beam['targetname'] = conditions.local_name( inst, beam['targetname', 'beam'] ) del beam['LightningEnd'] beam['origin'] = start_pos + Vec( -beam_off.x, beam_off.y, beam_off.z, ).rotate(*angles) beam['TargetPoint'] = end_pos + Vec( +beam_off.x, beam_off.y, beam_off.z, ).rotate(*angles) # Allow adding outputs to the last path_track. for prop in res.find_all('EndOutput'): output = Output.parse(prop) output.output = 'OnPass' output.inst_out = None output.comma_sep = False output.target = conditions.local_name(inst, output.target) last_track.add_out(output) if motion_filter is not None: motion_trig = vmf.create_ent( classname='trigger_multiple', targetname=conditions.local_name(inst, 'enable_motion_trig'), origin=start_pos, filtername=motion_filter, startDisabled=1, wait=0.1, ) motion_trig.add_out(Output('OnStartTouch', '!activator', 'ExitDisabledState')) # Match the size of the original... motion_trig.solids.append(vmf.make_prism( start_pos + Vec(72, -56, 58).rotate(*angles), end_pos + Vec(-72, 56, 144).rotate(*angles), mat='tools/toolstrigger', ).solid) if res.bool('NoPortalFloor'): # Block portals on the floor.. floor_noportal = vmf.create_ent( classname='func_noportal_volume', origin=beam_start, ) floor_noportal.solids.append(vmf.make_prism( start_pos + Vec(-60, -60, -66).rotate(*angles), end_pos + Vec(60, 60, -60).rotate(*angles), mat='tools/toolsinvisible', ).solid) # A brush covering under the platform. base_trig = vmf.make_prism( start_pos + Vec(-64, -64, 48).rotate(*angles), end_pos + Vec(64, 64, 56).rotate(*angles), mat='tools/toolsinvisible', ).solid vmf.add_brush(base_trig) # Make a paint_cleanser under the belt.. if res.bool('PaintFizzler'): pfizz = vmf.create_ent( classname='trigger_paint_cleanser', origin=start_pos, ) pfizz.solids.append(base_trig.copy()) for face in pfizz.sides(): face.mat = 'tools/toolstrigger'