Skip to content

Talk to a character ​

Goal ​

A talking character (an AI NPC on Convorcher) the player walks up to, presses E at and talks to in their own words, typed or spoken, while the character answers aloud with subtitles and a moving face; the game hears what was said and how the story is going. Needs Set up talking characters and Write a character's story.

Files you will edit ​

  • src/systems/talk.ts
  • src/game.ts

Steps ​

  1. The talk system. src/systems/talk.ts holds the character's prefab (tagged Talks, not Npc, so the scripted dialogue leaves it alone), starts the talk on E within reach, asks what the player says, and ends the talk when they walk away.

    ts
    import { Transform, conversation, prefab, query, type GameContext } from 'gameable';
    
    import { NPC_HALF_HEIGHT, NPC_RADIUS } from '../prefabs';
    
    /**
     * Tag: this NPC talks through the conversation service. Define it once: tags are matched by reference, so a second
     * `Talks` (one in `src/prefabs.ts` too) makes the query below find nobody. Import this one everywhere else.
     */
    export const Talks: Record<string, never> = {};
    
    /** The guard: `char.guard` is its character asset, `guard` its story (`src/guard.story.yaml`). */
    export const GuardPrefab = prefab({
      name: 'guard',
      body: {
        shape: 'capsule',
        dims: [NPC_RADIUS, NPC_HALF_HEIGHT],
        // kinematic, not `character`: two character controllers slide through each other
        kind: 'kinematic',
        mass: 70,
        layer: { character: true },
        mask: { defaultLayer: true, staticGeometry: true, player: true, character: true },
      },
      character: 'char.guard',
      components: [Talks],
    });
    
    const TALK_ID = 'guard';
    const REACH = 2.2;
    export const talkState = { npc: 0, open: false, lastRevision: -1 };
    
    export function talkSystem(ctx: GameContext): void {
      for (const event of ctx.events) {
        if (event.tag !== 'conversation-event' || event.val.entity !== talkState.npc) continue;
        const { kind, text } = event.val;
        if (kind === 'input' && talkState.open)
          conversation.command(talkState.npc, 'ask', TALK_ID, text);
        else if (kind === 'story') {
          const story = conversation.story(text);
          if (story && story.revision > talkState.lastRevision)
            talkState.lastRevision = story.revision;
        } else if (kind === 'error' && text.startsWith('closed:')) talkState.open = false;
      }
      if (ctx.player === 0) return;
      for (const npc of query(ctx.world, [Talks])) {
        const near = distance(ctx.player, npc) < REACH;
        if (!talkState.open && near && ctx.input.pressed('E')) {
          talkState.npc = npc;
          talkState.open = true;
          talkState.lastRevision = -1;
          conversation.command(npc, 'start', TALK_ID);
        } else if (talkState.open && npc === talkState.npc && !near) {
          talkState.open = false;
          conversation.command(npc, 'end', TALK_ID);
        }
      }
    }
    
    function distance(a: number, b: number): number {
      return Math.hypot(
        (Transform.x[a] ?? 0) - (Transform.x[b] ?? 0),
        (Transform.z[a] ?? 0) - (Transform.z[b] ?? 0),
      );
    }

    An error that starts with retry: keeps the talk open: the player just asks again. It is the service's fault, never the player's. For an E: talk prompt, show it from src/hud.ts when talkState.open is false and the player is in reach.

  2. Put them in the game. In src/game.ts, add talkSystem to systems and the guard to spawns:

    ts
    spawns: [place(GuardPrefab, { position: [0, 0, 4], yaw: Math.PI }, NPC_CENTRE) /* , ... */],
    systems: [/* ... */ talkSystem],

Verify ​

  • Walk to the guard and press E: the talk box opens at the bottom of the page.
  • Type "I need to get to my mother's farm": the guard answers aloud, the subtitle shows it and the face moves.
  • A story event follows the reply, with gave-reason in completedObjectives once you have said why.
  • Walk away: the talk box closes.

Going further ​

See also ​