def generate_coop_responses(ctx: Context) -> None: """If the entities are present, add the coop response script.""" responses: Dict[str, List[str]] = {} for response in ctx.vmf.by_class['bee2_coop_response']: responses[response['type']] = [ value for key, value in response.keys.items() if key.startswith('choreo') ] response.remove() if not responses: return script = ["BEE2_RESPONSES <- {"] for response_type, lines in sorted(responses.items()): script.append(f'\t{response_type} = [') for line in lines: script.append(f'\t\tCreateSceneEntity("{line}"),') script.append('\t],') script.append('};') # We want to write this onto the '@glados' entity. ent: Optional[Entity] = None for ent in ctx.vmf.by_target['@glados']: ctx.add_code(ent, '\n'.join(script)) # Also include the actual script. split_script = ent['vscripts'].split() split_script.append('bee2/coop_responses.nut') ent['vscripts'] = ' '.join(split_script) if ent is None: LOGGER.warning('Response scripts present, but @glados is not!')
def vscript_init_code(ctx: Context): """Add vscript_init_code keyvalues. The specified code is appended as a script file to the end of the scripts. vscript_init_code2, 3 etc will also be added in order if they exist. """ for ent in ctx.vmf.entities: code = ent.keys.pop('vscript_init_code', '') if not code: continue for i in itertools.count(2): extra = ent.keys.pop('vscript_init_code' + str(i), '') if not extra: break code += '\n' + extra ctx.add_code(ent, code)
def comp_relay(ctx: Context): """Implements comp_relay, allowing zero-overhead relay ents for managing outputs. These are collapsed into their callers. """ # Output -> input that we convert. out_names = { 'ontrigger': 'trigger', 'onturnedon': 'turnon', 'onturnedoff': 'turnoff', } # Add user outputs as well. for i in '12345678': out_names['onuser' + i] = 'fireuser' + i for relay in ctx.vmf.by_class['comp_relay']: # First, see if any entities exist with the same name that aren't # comp_relays. In that case, we need to keep the inputs. relay_name = relay['targetname'] should_remove = not any(ent['classname'].casefold() != 'comp_relay' for ent in ctx.vmf.by_target[relay_name]) # If ctrl_type is 0, ctrl_value needs to be 1 to be enabled. # If ctrl_type is 1, ctrl_value needs to be 0 to be enabled. enabled = conv_bool(relay['ctrl_type']) != conv_bool( relay['ctrl_value']) for out in relay.outputs: try: inp_name = out_names[out.output.casefold()] except KeyError: LOGGER.warning( 'Unknown output "{}" on comp_relay "{}"!\n' 'This will be discarded.', out.output, relay_name, ) continue if enabled: out.output = inp_name ctx.add_io_remap(relay_name, out, remove=should_remove) elif should_remove: # Still add a remap, to remove the outputs. ctx.add_io_remap_removal(relay_name, inp_name) relay.remove()
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 comp_scriptvar(ctx: Context): """An entity to allow setting VScript variables to information from the map.""" # {ent: {variable: data}} set_vars = defaultdict( lambda: defaultdict(VarData)) # type: Dict[Entity, Dict[str, VarData]] # If the index is None, there's no index. # If an int, that specific one. # If ..., blank index and it's inserted anywhere that fits. for comp_ent in ctx.vmf.by_class['comp_scriptvar_setter']: comp_ent.remove() var_name = orig_var_name = comp_ent['variable'] index = None # type: Union[int, Type[Ellipsis], None] parsed_match = re.fullmatch(r'\s*([^[]+)\[([0-9]*)\]\s*', var_name) if parsed_match: var_name, index_str = parsed_match.groups() if index_str: try: index = int(index_str) if index < 0: raise ValueError # No negatives. except (TypeError, ValueError): LOGGER.warning( 'Invalid variable index in ' 'comp_scriptvar at {} targetting "{}"!', comp_ent['origin'], comp_ent['target']) continue else: index = ... elif '[' in var_name or ']' in var_name: LOGGER.warning( 'Unparsable variable[index] in ' 'comp_scriptvar at {} targetting "{}"!', comp_ent['origin'], comp_ent['target']) continue ref_name = comp_ent['ref'] ref_ent = comp_ent if ref_name: for ref_ent in ctx.vmf.search(ref_name): break else: LOGGER.warning( 'Can\'t find ref entity named "{}" ' 'for comp_scriptvar at <{}>!', ref_name, comp_ent['origin'], ) try: mode_func = MODES[comp_ent['mode']] except KeyError: LOGGER.warning( 'Invalid mode "{}" in ' 'comp_scriptvar at {} targeting "{}"!', comp_ent['mode'], comp_ent['origin'], comp_ent['target'], ) continue else: code = mode_func(comp_ent, ref_ent) ent = None for ent in ctx.vmf.search(comp_ent['target']): var_data = set_vars[ent][var_name] # Now we've got to match the assignment this is doing # with other scriptvars. # First, take care of scalar assignment. if index is None: if var_data.is_array: LOGGER.warning( "comp_scriptvar at {} can't set a non-array value " 'on top of the array {} on the entity "{}"!', comp_ent['origin'], var_name, comp_ent['target'], ) elif var_data.scalar is not None: LOGGER.warning( 'comp_scriptvar at {} overwrote ' 'the variable {} on "{}"!', comp_ent['origin'], var_name, comp_ent['target'], ) else: var_data.scalar = code continue # Else, we're setting an array value. if var_data.scalar is not None: LOGGER.warning( "comp_scriptvar at {} can't set an array value " 'on top of the non-array {} on the entity "{}"!', comp_ent['origin'], var_name, comp_ent['target'], ) continue if index is Ellipsis: var_data.extra_pos.add(code) else: # Allow duplicates that write the exact same thing, # as a special case. if var_data.specified_pos.get(index, code) != code: LOGGER.warning( "comp_scriptvar at {} can't " 'overwrite {}[{}] on the entity "{}"!', comp_ent['origin'], var_name, index, comp_ent['target'], ) else: var_data.specified_pos[index] = code if ent is None: # No targets? LOGGER.warning( 'No entities found with name "{}", for ' 'comp_scriptvar at {}!', comp_ent['target'], comp_ent['origin'], ) for ent, var_dict in set_vars.items(): full_code = [] for var_name, var_data in var_dict.items(): full_code.append('{} <- {};'.format(var_name, var_data.make_code())) if full_code: ctx.add_code(ent, '\n'.join(full_code))