累计送礼: 0 个 累计收礼: 1 个 TA的每日心情 开心 2026-7-12 04:10
签到天数: 209 天
连续签到: 2 天
[LV.7]常住居民III
管理员
VIP
7
卡币
25563
OK点
16
推广点
0
同能卷
50
积分 32235
I was programming in Python as usual and using GROQ's API, and I thought, why not create something for RPG Maker MZ too? Since I just finished developing my FOW Minimap , I sat down and wrote some quick code.
I'm giving it away here for free, I'm not developing it further. Take it and do whatever you want – MIT License. I made it as a hobby project, it's not a plugin I'll maintain. Here's what this version offers:
GroqNPC.js – AI-Powered Dynamic NPC Engine for RPG Maker MZ
Version: 2.2.0
License: MIT – do whatever you want with it
Requirements: RPG Maker MZ, Groq API key (free tier available)
What's Inside
This plugin connects your RPG Maker MZ game to
Groq's fast LLM API (Llama 3.1) to deliver four integrated systems:
Layer - Feature - What It Does
1 - AI Dialogue - Streaming conversations with memory, 10s timeout
2 - Quest Machine - Define quests; all NPCs share state automatically
3 - NPC Knowledge - NPCs know quest status, companions, world state
4 - Enemy Behavior - AI decides tactics, sets MZ switches before battle
Quick Start – Dragon Hunt Example
1. Define the quest (in an autorun event)
Code:
Plugin Command: Define Quest Quest ID: dragon_hunt Title: The Dragon of Ashvale Objectives: Talk to Mark|Find the lair|Defeat the dragon NPC Members: Mark,Sera,Old Tom Quest Switch: 10 Quest Variable: 11 复制代码
2. Train Mark – the quest giver (in his event)
Code:
Plugin Command: Train NPC Persona Name: Mark Personality: You are Mark, a grizzled dragon hunter. You lost your left eye to the Ashvale Dragon 20 years ago. You speak in a gruff, tired voice and constantly warn about danger. Role: questgiver Quest ID: dragon_hunt Guardrails: Never tell the player the dragon is easy to beat Fallback Message: I ain't lyin' to you about that beast. Plugin Command: Start AI Dialogue. 复制代码
3. Train a companion (Sera's event)
Code:
Plugin Command: Train NPC Persona Name: Sera Personality: You are Sera, a young eager mage who wants to prove herself. You admire Mark but think he's too cautious. You speak quickly and enthusiastically. Role: companion Quest ID: dragon_hunt Max Tokens: 150 Plugin Command: Start AI Dialogue 复制代码
4. Enemy behavior – before dragon battle
Code:
Plugin Command: Evaluate Enemy Behavior Enemy Name: Ashvale Dragon Enemy Description: Ancient red dragon, wounded but enraged. Scales are cracked on the left side (Mark's old wound). Breathes fire every 3 turns. Quest ID: dragon_hunt Behavior Switch Base: 20 Player HP % Variable: 1 复制代码
Then check switches in your battle event:
Switch 20 ON → Aggressive (full attack)
Switch 21 ON → Defensive (guard/buff)
Switch 22 ON → Flee (escape attempt)
Switch 23 ON → Reinforce (call allies)
Switch 24 ON → Enraged (berserk mode)
5. Advance the quest anywhere
Code:
Plugin Command: Advance Quest Objective Quest ID: dragon_hunt 复制代码
All NPCs linked to dragon_hunt will automatically know the new objective.
Text Formatting (AI → MZ Color Codes)
The AI wraps key words with semantic tags. The plugin converts them to MZ colors:
Tag - Purpose - MZ Color
Code:
[important]text[/important] 复制代码
- Dangers, warnings, critical info - Red (2)
Code:
[highlight]text[/highlight] 复制代码
- Names, places, items, proper nouns - Yellow (14)
Code:
- Secrets, rare knowledge, confidential - Green (3)
Code:
- Rumors, uncertain info, vague hints - Gray (8)
Example AI response:
Code:
DIALOGUE: The [highlight]Dragon of Ashvale[/highlight] sleeps near the [highlight]eastern caves[/highlight]. It is [important]extremely dangerous[/important] – its fire can melt steel in seconds. PROMPT: Ask about the weak spot? 复制代码
What the player sees: Colored text automatically (yellow for locations, red for warnings, etc.)
Plugin Parameters (Configure in Plugin Manager)
Parameter - Default - Description
Code:
- (Empty) - Get from console.groq.com/keys
Code:
- llama-3.1-8b-instant - Visit HERE for more
Code:
- 10 seconds - Hard cutoff. Partial text shown if triggered
Code:
- 4 exchanges - How many back-and-forth messages per NPC to remember
Code:
- What do you say? - Fallback when AI doesn't generate a prompt
Code:
- 100 characters - Limits what player can type
Code: Global System Prompt Suffix 复制代码
- (Empty) - Appended to ALL NPC prompts. Example: "Keep responses under 3 sentences. Never break character."
Code:
- Hmm... I need a moment to think - Shown when API times out
Code:
- false - Logs full API payloads to F8 console
Complete Plugin Command Reference
Quest Management
Command - Arguments - Description
Code:
-
questId, title, description, objectives (pipe-separated), npcMembers (comma), questSwitch, questVariable - reate/update a quest
Code:
- questId - Mark current objective done, move to next
Code:
- questId - Mark quest as fully completed
Code:
- questId - Mark quest as failed
NPC Persona
Command - Arguments - Description
Code:
- name, personality, role (none/questgiver/companion/merchant/informant), questId, guardrails, fallbackMessage, maxTokens - Define NPC personality. Call BEFORE startDialogue
Code:
- (none) - Begin AI conversation with this NPC
Code:
- (none) - Wipe this NPC's conversation history
Code:
- (none) - Wipe ALL NPC conversation histories
Enemy Behavior
Command - Arguments - Description
Code:
- enemyName, enemyDescription, questId, behaviorSwitchBase, playerHpPercent - AI decides behavior, sets MZ switches
Utilities
Command - Arguments - Description
Code:
- (none) - Verify API key works. Shows result in message window
Code:
- label, variableId or switchId - Expose game state to ALL AI calls globally
️ Notetags (Alternative to Train Persona)
Place these in the
event's Note field :
Code:
<AIPersona: You are a paranoid old hermit who hoards mushrooms and mistrusts strangers.> <AIRole: informant> <AIQuestID: dragon_hunt> <AIGuardrails: Never talk about the royal family. Never reveal the dragon's location directly.> <AIFallback: I... I cannot speak of such things... The walls have ears.> <AIMaxTokens: 150> 复制代码
Note: Train Persona plugin commands override notetags.
NPC Memory & Knowledge System
What NPCs Know Automatically
Quest status – all quests linked to them via questId
Current objective – which step the player is on
Quest members – names of all NPCs in the same quest
World state – any variables/switches exposed via setContextVar
Conversation history – last N exchanges (configurable)
Example: Exposing World State
Code:
Plugin Command: Set Context Variable Label: Player defeated the dragon Switch ID: 25 复制代码
Now
EVERY NPC sees this in their system prompt:
Code:
[WORLD STATE] Player defeated the dragon: true 复制代码
They will react accordingly. A merchant might say:
"I heard you slew the dragon! Take this discount."
Memory Management
Memory is saved in the save file
Each NPC has independent memory (by map ID + event ID)
Use Clear NPC Memory to reset an NPC
Use Clear All NPC Memory to reset everyone
Enemy Behavior Deep Dive
Behavior Switch Mapping
When you call evaluateEnemyBehavior with Behavior Switch Base: 20:
Switch - Behavior - AI Decides This When
20 -
Aggressive - Player HP is low, enemy has advantage, quest near start
21 -
Defensive - Enemy is wounded, protecting something, buying time
22 -
Flee - Enemy is outmatched, mission objective complete, low HP
23 -
Reinforce - Enemy is alone vs party, quest climax approaching
24 -
Enraged - Quest-specific trigger (e.g., dragon at 30% HP, companion died)
Only ONE switch is ON at a time. Use conditional branches to check them.
Enemy Evaluation Example
The AI receives this system prompt internally:
Code:
You are an AI Game Master deciding enemy combat behavior. Enemy: Ashvale Dragon Description: Ancient red dragon, wounded but enraged. [QUEST: The Dragon of Ashvale] Status: ACTIVE Objectives: DONE: Talk to Mark CURRENT: Find the lair PENDING: Defeat the dragon Player current HP: 45% [WORLD STATE] Player has the Dragon Slayer sword: true 复制代码
Choose ONE behavior: aggressive / defensive / flee / reinforce / enraged
The AI responds with a single word, and the plugin sets the corresponding switch.
Save File Compatibility
The plugin saves the following data automatically:
_npcMemory – conversation histories
_trainedPersonas – all trained NPC personalities
_quests – quest states (status, current objective)
_ctxVars – exposed context variables
No special handling needed. Save and load normally.
Limitations & Warnings
Issue - Detail
API key visibility - Client-side calls mean the API key is visible in player's browser. Obfuscation possible but not implemented.
Internet required - Game won't work offline for AI features. Regular MZ gameplay unaffected.
API costs - You pay for usage. Groq is very cheap (~$0.0002 per dialogue) but not free.
Steam policy - Must declare AI-generated content in your store listing.
No ongoing development - I made this for fun. It works as-is. I won't fix bugs or add features.
Troubleshooting
"API key is empty" error
Check Plugin Manager → GroqNPC → Groq API Key
Key should start with gsk_
No spaces before or after
Timeout errors
Increase Stream Timeout parameter (10-15 seconds recommended)
Switch to llama-3.1-8b-instant model (faster)
No response or weird formatting
Enable Debug Mode and check F8 console
Verify your Groq account has credits
Test with testApiKey plugin command
NPC doesn't remember quest
Ensure Quest ID matches exactly (case-sensitive)
Call trainPersona BEFORE startDialogue
Check that the quest was defined (run defineQuest first)
License & Credits
MIT License – do literally anything you want.
No credit required (but appreciated)
No warranty
No support
Built with:
Final Words
That's it. Take it, break it, fix it, improve it, sell games with it.
If you make something cool, I'd love to see it. If you don't, that's fine too.
Enjoy!
↓FULL CODE ↓
Spoiler JavaScript:
//============================================================================= //============================================================================= // GroqNPC.js - Groq-Powered Dynamic NPC Engine for RPG Maker MZ // Version: 2.2.0 // Changelog: // 2.0.0 - Quest State Machine, NPC Knowledge Sharing, Enemy Behavior AI, // dynamic input prompt, structured AI response (DIALOGUE/PROMPT) // 1.2.0 - Hybrid text formatting (semantic tags -> MZ color codes) // 1.1.0 - Streaming API, 10s timeout, Train NPC Persona command // 1.0.0 - Initial release //============================================================================= // LEGAL DISCLAIMER: // Client-side calls to Groq API. You accept Groq ToS (https://groq.com/terms). // Declare AI-generated content in your store listing (Steam policy). // Author not liable for API costs or AI-generated content. //============================================================================= /*: * @target MZ * @plugindesc v2.2 | Groq AI Game Master - Dynamic NPCs, Quest Machine, Enemy Behavior * @author Rpx * @url https://gdschool.education/ * * @help * ============================================================================ * GROQ NPC ENGINE v2.0 - OVERVIEW * ============================================================================ * * Four systems work together: * LAYER 1 - AI Dialogue Streaming NPC conversation, max 10s response * LAYER 2 - Quest Machine Define quests; all tagged NPCs share state * LAYER 3 - NPC Knowledge NPCs know quest status, companions, world state * LAYER 4 - Enemy Behavior AI decides enemy behavior, sets MZ Switches * * ============================================================================ * QUICK START - DRAGON HUNTER QUEST EXAMPLE * ============================================================================ * * 1. In a map autorun or quest-giver event: * [Plugin Command] Define Quest * ID: dragon_hunt | Title: The Dragon of Ashvale * Objectives: Talk to Mark|Find the lair|Defeat the dragon * Members: Mark,Sera,Old Tom | Quest Switch: 10 * * 2. Mark's event (quest giver): * [Plugin Command] Train NPC Persona * Name: Mark | Role: questgiver | Quest ID: dragon_hunt * Personality: You are Mark, veteran dragon hunter... * [Plugin Command] Start AI Dialogue * * 3. Companion events (Sera, Old Tom): * [Plugin Command] Train NPC Persona * Role: companion | Quest ID: dragon_hunt * [Plugin Command] Start AI Dialogue * * 4. Dragon enemy event (before battle): * [Plugin Command] Evaluate Enemy Behavior * Enemy: Ashvale Dragon | Quest ID: dragon_hunt * Behavior Switch Base: 20 * [Conditional Branch] Switch 20 ON -> aggressive pattern * [Conditional Branch] Switch 21 ON -> defensive pattern * [Conditional Branch] Switch 22 ON -> flee * [Conditional Branch] Switch 23 ON -> call reinforcements * [Conditional Branch] Switch 24 ON -> enraged * * 5. Advance quest anywhere: * [Plugin Command] Advance Quest Objective | Quest ID: dragon_hunt * * ============================================================================ * NOTETAGS (Event Note field - overridden by Train NPC Persona command) * ============================================================================ * * <AIPersona: [description]> * <AIGuardrails: [rules]> * <AIFallback: [message]> * <AIMaxTokens: [number]> * <AIQuestID: [questId]> * <AIRole: questgiver|companion|merchant|informant> * * ============================================================================ * TEXT FORMATTING TAGS * ============================================================================ * * AI wraps key words - plugin converts to MZ color codes: * [important]...[/important] -> Red (dangers, warnings) * [highlight]...[/highlight] -> Yellow (names, places, items) * [secret]...[/secret] -> Green (secrets, rare info) * [weak]...[/weak] -> Gray (rumors, uncertain) * * ============================================================================ * ENEMY BEHAVIOR SWITCHES (Base ID set in Evaluate Enemy Behavior command) * ============================================================================ * * Base+0 -> Aggressive (full attack) * Base+1 -> Defensive (guard/buff) * Base+2 -> Flee (escape attempt) * Base+3 -> Reinforce (call allies) * Base+4 -> Enraged (quest-triggered berserk) * * Only ONE switch is ON at a time. Use Conditional Branch on each. * * ============================================================================ * * @param apiKey * @text Groq API Key * @type string * @desc Get one at https://console.groq.com/keys * @default * * @param model * @text AI Model * @type combo * @option llama-3.1-8b-instant * @option llama-3.3-70b-versatile * @desc llama-3.1-8b-instant = fastest + cheapest. Recommended. * @default llama-3.1-8b-instant * * @param streamTimeout * @text Stream Timeout (seconds) * @type number * @min 3 * @max 30 * @desc Hard cutoff. Partial text shown if triggered. * @default 10 * * @param maxMemory * @text Chat Memory (exchanges per NPC) * @type number * @min 1 * @max 20 * @default 4 * * @param defaultInputPrompt * @text Default Input Prompt * @type string * @desc Fallback when AI does not generate a contextual prompt. * @default What do you say? * * @param maxInputLength * @text Max Player Input Length * @type number * @min 10 * @max 200 * @default 100 * * @param globalSystemSuffix * @text Global System Prompt Suffix * @type multiline_string * @desc Appended to ALL NPC system prompts. * @default Keep responses under 3 sentences. Stay in character. Never mention being an AI. * * @param timeoutMessage * @text Timeout Fallback Message * @type string * @default Hmm... I need a moment to think. * * @param enableFormatting * @text Enable AI Text Formatting * @type boolean * @default true * * @param colorImportant * @text Color: [important] * @type number * @min -1 * @max 31 * @desc MZ color index. 2=red. -1=disabled. * @default 2 * * @param colorHighlight * @text Color: [highlight] * @type number * @min -1 * @max 31 * @desc MZ color index. 14=yellow. -1=disabled. * @default 14 * * @param colorSecret * @text Color: [secret] * @type number * @min -1 * @max 31 * @desc MZ color index. 3=green. -1=disabled. * @default 3 * * @param colorWeak * @text Color: [weak] * @type number * @min -1 * @max 31 * @desc MZ color index. 8=gray. -1=disabled. * @default 8 * * @param debugMode * @text Debug Mode * @type boolean * @desc Log full payloads to F8 console. * @default false * * @command testApiKey * @text Test API Connection * @desc Run this in any event to verify your API key works. Shows result in a message window. * * @command defineQuest * @text Define Quest * @desc Create/update a quest. All linked NPCs share this quest state. * * @arg questId * @text Quest ID * @type string * @desc Unique ID, no spaces. e.g. "dragon_hunt" * @default my_quest * * @arg title * @text Quest Title * @type string * @default The Quest * * @arg description * @text Quest Description * @type multiline_string * @default A quest to be completed. * * @arg objectives * @text Objectives (pipe-separated) * @type string * @desc e.g. "Find Mark|Reach the cave|Defeat the dragon" * @default Objective 1|Objective 2|Objective 3 * * @arg npcMembers * @text NPC Members (comma-separated) * @type string * @desc Names of all NPCs involved. They all share quest knowledge. * @default NPC1,NPC2 * * @arg questSwitch * @text Quest Active Switch ID * @type switch * @desc Set ON when quest is active. 0 = none. * @default 0 * * @arg questVariable * @text Quest Progress Variable ID * @type variable * @desc Stores current objective index (0-based). 0 = none. * @default 0 * * @command advanceObjective * @text Advance Quest Objective * @desc Mark current objective done, move to next. * * @arg questId * @text Quest ID * @type string * @default my_quest * * @command completeQuest * @text Complete Quest * @desc Mark quest as fully completed. * * @arg questId * @text Quest ID * @type string * @default my_quest * * @command failQuest * @text Fail Quest * @desc Mark quest as failed. * * @arg questId * @text Quest ID * @type string * @default my_quest * * @command trainPersona * @text Train NPC Persona * @desc Define NPC personality and quest link. Call BEFORE Start AI Dialogue. * * @arg name * @text NPC Name * @type string * @default Villager * * @arg personality * @text Personality & Background * @type multiline_string * @desc Role, traits, knowledge, speech style. * @default You are a friendly villager in a medieval fantasy town. * * @arg role * @text Quest Role * @type select * @option none * @option questgiver * @option companion * @option merchant * @option informant * @default none * * @arg questId * @text Quest ID (optional) * @type string * @desc Link this NPC to a quest. Leave empty for standalone. * @default * * @arg guardrails * @text Forbidden Topics * @type multiline_string * @default * * @arg fallbackMessage * @text Guardrail Fallback * @type string * @default I cannot speak of such things. * * @arg maxTokens * @text Max Response Tokens * @type number * @min 30 * @max 400 * @default 120 * * @command startDialogue * @text Start AI Dialogue * @desc Begin AI conversation with this NPC. * * @command evaluateEnemyBehavior * @text Evaluate Enemy Behavior * @desc AI decides enemy behavior and sets MZ Switches. * * @arg enemyName * @text Enemy Name * @type string * @default Enemy * * @arg enemyDescription * @text Enemy Description * @type multiline_string * @desc Personality, lore, combat context. * @default A fierce monster guarding its territory. * * @arg questId * @text Quest ID (optional) * @type string * @default * * @arg behaviorSwitchBase * @text Behavior Switch Base ID * @type switch * @desc Base+0=Aggressive, Base+1=Defensive, Base+2=Flee, Base+3=Reinforce, Base+4=Enraged * @default 1 * * @arg playerHpPercent * @text Player HP % Variable * @type variable * @desc Variable with player HP 0-100. 0 = not used. * @default 0 * * @command setContextVar * @text Set Context Variable * @desc Expose a game variable or switch to all AI calls globally. * * @arg label * @text Label * @type string * @desc Human-readable name. e.g. "Player defeated the dragon" * * @arg variableId * @text Variable ID * @type variable * * @arg switchId * @text Switch ID * @type switch * * @command clearMemory * @text Clear NPC Memory * @desc Wipe this NPC's conversation history. * * @command clearAllMemory * @text Clear All NPC Memory * @desc Wipe ALL NPC conversation histories. */ (() => { "use strict"; const PLUGIN_NAME = "GroqNPC"; const LOG_PREFIX = "[GroqNPC]"; const API_URL = "https://api.groq.com/openai/v1/chat/completions"; //========================================================================= // Config //========================================================================= const params = PluginManager.parameters(PLUGIN_NAME); // Parsing robusto: trim + gestione undefined/null const _rawKey = params["apiKey"] !== undefined ? String(params["apiKey"]).trim() : ""; const Config = { apiKey : _rawKey, model : String(params.model || "llama-3.1-8b-instant"), streamTimeout : Number(params.streamTimeout || 10) * 1000, maxMemory : Number(params.maxMemory || 4), defaultPrompt : String(params.defaultInputPrompt || "What do you say?"), maxInputLen : Number(params.maxInputLength || 100), globalSuffix : String(params.globalSystemSuffix || ""), timeoutMsg : String(params.timeoutMessage || "Hmm... I need a moment to think."), enableFmt : params.enableFormatting !== "false", colorImportant: Number(params.colorImportant ?? 2), colorHighlight: Number(params.colorHighlight ?? 14), colorSecret : Number(params.colorSecret ?? 3), colorWeak : Number(params.colorWeak ?? 8), debugMode : params.debugMode === "true", }; const log = (...a) => { if (Config.debugMode) console.log(LOG_PREFIX, ...a); }; const warn = (...a) => console.warn(LOG_PREFIX, ...a); const lerr = (...a) => console.error(LOG_PREFIX, ...a); //========================================================================= // LAYER 2 - Quest State Machine //========================================================================= const _quests = {}; function getQuest(id) { return _quests[id] || null; } function buildQuestContext(questId) { const q = getQuest(questId); if (!q) return ""; const objectives = q.objectives.map((obj, i) => { const done = i < q.currentObjective; const cur = i === q.currentObjective && q.status === "active"; return ` ${done ? "DONE" : cur ? "CURRENT" : "PENDING"}: ${obj}`; }).join("\n"); return `\n[QUEST: ${q.title}] Status: ${q.status.toUpperCase()} Description: ${q.description} Objectives:\n${objectives} Active objective: ${q.objectives[q.currentObjective] || "All complete"} Quest members: ${q.npcMembers.join(", ")}`; } function advanceQuestObjective(questId) { const q = getQuest(questId); if (!q || q.status !== "active") return; q.currentObjective = Math.min(q.currentObjective + 1, q.objectives.length - 1); if (q.questVariable > 0) $gameVariables.setValue(q.questVariable, q.currentObjective); log("Quest advanced:", questId, "-> objective", q.currentObjective); } //========================================================================= // LAYER 3 - NPC Memory & Knowledge //========================================================================= const _npcMemory = {}; const _trainedPersonas = {}; const memKey = (m, e) => `${m}_${e}`; const getMem = (m, e) => { const k = memKey(m,e); if (!_npcMemory[k]) _npcMemory[k]=[]; return _npcMemory[k]; }; const clearMem = (m, e) => { _npcMemory[memKey(m,e)] = []; }; function pushMem(mapId, evId, role, content) { const mem = getMem(mapId, evId); mem.push({ role, content: stripTags(content) }); const max = Config.maxMemory * 2; while (mem.length > max) mem.shift(); } //========================================================================= // Context Variables //========================================================================= const _ctxVars = []; function buildCtxString() { const lines = _ctxVars.map(cv => { if (cv.variableId > 0) return `${cv.label}: ${$gameVariables.value(cv.variableId)}`; if (cv.switchId > 0) return `${cv.label}: ${$gameSwitches.value(cv.switchId) ? "true" : "false"}`; return null; }).filter(Boolean); return lines.length ? "\n[WORLD STATE]\n" + lines.join("\n") : ""; } //========================================================================= // Notetag Parser //========================================================================= function parseNotetags(event) { const note = event.event().note || ""; const get = (tag, def) => { const m = note.match(new RegExp(`<${tag}:\\s*([^>]+)>`, "i")); return m ? m[1].trim() : def; }; return { name : get("AIName", "NPC"), persona : get("AIPersona", "You are a villager in a fantasy RPG."), guardrails: get("AIGuardrails",""), fallback : get("AIFallback", "I cannot speak of such things."), maxTokens : Number(get("AIMaxTokens", "120")), questId : get("AIQuestID", ""), role : get("AIRole", "none"), }; } //========================================================================= // System Prompt Builder //========================================================================= const ROLE_DESC = { questgiver: "You are the quest giver. Explain objectives, urgency, and reward clearly.", companion : "You are a companion in this quest. Share tips, morale, and quest details.", merchant : "You are a merchant. Sell useful items and share quest-related rumors.", informant : "You are an informant. Share world information carefully and selectively.", }; const FORMAT_INSTRUCTIONS = ` [TEXT FORMATTING - USE SPARINGLY, MAX 4 WORDS PER TAG] [important]...[/important] = dangers, warnings, critical info [highlight]...[/highlight] = item names, locations, proper nouns [secret]...[/secret] = secrets, rare knowledge, confidential [weak]...[/weak] = rumors, uncertain info, vague hints`; const RESPONSE_FORMAT = ` [MANDATORY RESPONSE FORMAT] Reply using EXACTLY this structure, no exceptions: DIALOGUE: <your in-character reply, 1-3 sentences> PROMPT: <short question hint for player input, max 6 words> Example: DIALOGUE: The [highlight]Dragon of Ashvale[/highlight] sleeps near the [highlight]eastern caves[/highlight]. It is [important]extremely dangerous[/important]. PROMPT: Ask about the reward?`; function buildSysPrompt(tags) { let p = `You are ${tags.name}. ${tags.persona}`; if (ROLE_DESC[tags.role]) p += `\nRole: ${ROLE_DESC[tags.role]}`; if (tags.questId) { const qCtx = buildQuestContext(tags.questId); if (qCtx) p += qCtx; } if (tags.guardrails) { p += `\n[STRICT RULES]\n${tags.guardrails}`; p += `\nIf forbidden topic raised, say ONLY: "${tags.fallback}"`; } const ctx = buildCtxString(); if (ctx) p += ctx; if (Config.enableFmt) p += FORMAT_INSTRUCTIONS; p += RESPONSE_FORMAT; if (Config.globalSuffix) p += "\n" + Config.globalSuffix; log("System prompt built for:", tags.name, "| quest:", tags.questId); log("Full prompt:", p); return p; } //========================================================================= // LAYER 4 - Enemy Behavior Evaluator //========================================================================= const ENEMY_BEHAVIORS = ["aggressive", "defensive", "flee", "reinforce", "enraged"]; async function evaluateEnemyBehavior(enemyName, enemyDesc, questId, playerHpPct) { if (!Config.apiKey) { lerr("API key empty in evaluateEnemyBehavior. Raw:", params["apiKey"]); throw new Error("Groq API key not set. Check Plugin Manager."); } let sys = `You are an AI Game Master deciding enemy combat behavior. Enemy: ${enemyName} ${enemyDesc ? "Description: " + enemyDesc : ""}`; if (questId) { const qCtx = buildQuestContext(questId); if (qCtx) sys += qCtx; } if (playerHpPct > 0) sys += `\nPlayer current HP: ${playerHpPct}%`; sys += buildCtxString(); sys += ` [YOUR TASK] Based on all context, choose ONE behavior for this enemy: - aggressive: full attack, no mercy, player is manageable - defensive: protect self, use guards/buffs, buying time - flee: escape, enemy is outmatched or mission complete - reinforce: call for allies, enemy needs backup now - enraged: quest-triggered power surge, goes berserk (use when quest reaches climax) Respond with ONLY one word. No punctuation, no explanation.`; const payload = { model : Config.model, max_tokens: 5, stream : false, messages : [ { role: "system", content: sys }, { role: "user", content: "Choose behavior." } ], }; log("Enemy eval payload:", JSON.stringify(payload)); const res = await fetch(API_URL, { method : "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${Config.apiKey}` }, body : JSON.stringify(payload), }); if (!res.ok) throw new Error(`Groq API ${res.status}`); const data = await res.json(); const behavior = (data.choices[0].message.content || "").trim().toLowerCase().split(/[\s\n]/)[0]; const idx = ENEMY_BEHAVIORS.indexOf(behavior); log(`Enemy "${enemyName}" behavior: "${behavior}" -> index ${idx >= 0 ? idx : 0}`); return idx >= 0 ? idx : 0; } //========================================================================= // Text Formatting //========================================================================= function stripTags(text) { return text .replace(/\[(important|highlight|secret|weak)\]([\s\S]*?)\[\/\1\]/gi, "$2") .replace(/\[(\/?)(?:important|highlight|secret|weak)\]/gi, ""); } function convertTags(text) { if (!Config.enableFmt) return stripTags(text); const map = { important: Config.colorImportant, highlight: Config.colorHighlight, secret : Config.colorSecret, weak : Config.colorWeak, }; let r = text; for (const [tag, idx] of Object.entries(map)) { if (idx < 0) { r = r.replace(new RegExp(`\\[${tag}\\]([\\s\\S]*?)\\[\\/${tag}\\]`, "gi"), "$1"); } else { r = r.replace( new RegExp(`\\[${tag}\\]([\\s\\S]*?)\\[\\/${tag}\\]`, "gi"), `\\C[${idx}]$1\\C[0]` ); } } return r.replace(/\[(\/?)(?:important|highlight|secret|weak)\]/gi, ""); } //========================================================================= // Structured Response Parser - DIALOGUE: / PROMPT: //========================================================================= function parseResponse(raw) { const dm = raw.match(/DIALOGUE:\s*([\s\S]*?)(?=\nPROMPT:|$)/i); const pm = raw.match(/PROMPT:\s*(.+)/i); return { dialogue: (dm ? dm[1] : raw).trim(), prompt : (pm ? pm[1] : Config.defaultPrompt).trim(), }; } //========================================================================= // Groq Streaming //========================================================================= async function callGroqStream(sysPrompt, messages, maxTokens) { if (!Config.apiKey) { const msg = `Groq API key is empty. Check Plugin Manager > GroqNPC > Groq API Key. Raw value: "${params["apiKey"]}"`; lerr(msg); throw new Error(msg); } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), Config.streamTimeout); const payload = { model : Config.model, max_tokens: maxTokens, stream : true, messages : [{ role: "system", content: sysPrompt }, ...messages], }; let accumulated = ""; try { const res = await fetch(API_URL, { method : "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${Config.apiKey}` }, body : JSON.stringify(payload), signal : controller.signal, }); if (!res.ok) { const ed = await res.json().catch(() => ({})); throw new Error(`Groq API ${res.status}: ${ed?.error?.message || res.statusText}`); } const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); outer: while (true) { let chunk; try { chunk = await reader.read(); } catch (_) { log("Stream cut by timeout. Got:", accumulated); break outer; } if (chunk.done) break; for (const line of decoder.decode(chunk.value, { stream: true }).split("\n")) { if (!line.startsWith("data: ")) continue; const data = line.slice(6).trim(); if (data === "[DONE]") break outer; try { const delta = JSON.parse(data)?.choices?.[0]?.delta?.content; if (delta) accumulated += delta; } catch (_) {} } } } catch (e) { if (e.name !== "AbortError") { clearTimeout(timeoutId); throw e; } log("Fetch aborted (timeout)."); } clearTimeout(timeoutId); return accumulated.trim() || Config.timeoutMsg; } //========================================================================= // Window_NpcInput - dynamic prompt text //========================================================================= class Window_NpcInput extends Window_Base { initialize(rect) { super.initialize(rect); this._text = ""; this._active = false; this._prompt = Config.defaultPrompt; this._cursorTimer = 0; this._cursorVis = true; this._onConfirm = null; this._onCancel = null; this._boundKD = this._onKeyDown.bind(this); this.openness = 0; } activate(prompt, onConfirm, onCancel) { this._text = ""; this._prompt = prompt || Config.defaultPrompt; this._active = true; this._onConfirm = onConfirm; this._onCancel = onCancel; this.open(); this.refresh(); document.addEventListener("keydown", this._boundKD, true); } deactivate() { this._active = false; document.removeEventListener("keydown", this._boundKD, true); this.close(); } _onKeyDown(e) { if (!this._active) return; if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); const t = this._text.trim(); if (t) { this.deactivate(); this._onConfirm(t); } return; } if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); this.deactivate(); this._onCancel(); return; } if (e.key === "Backspace") { e.preventDefault(); this._text = this._text.slice(0, -1); this.refresh(); return; } if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) { if (this._text.length < Config.maxInputLen) { this._text += e.key; this.refresh(); } } } update() { super.update(); if (this._active && ++this._cursorTimer >= 30) { this._cursorTimer = 0; this._cursorVis = !this._cursorVis; this.refresh(); } } refresh() { this.contents.clear(); const lh = this.lineHeight(); this.changeTextColor(ColorManager.systemColor()); this.drawText(this._prompt, 0, 0, this.innerWidth, "left"); this.resetTextColor(); const cursor = this._cursorVis && this._active ? "|" : " "; this.drawText(this._text + cursor, 0, lh, this.innerWidth - 90, "left"); this.changeTextColor(ColorManager.textColor(8)); this.drawText(`${this._text.length}/${Config.maxInputLen}`, 0, lh, this.innerWidth, "right"); this.resetTextColor(); this.changeTextColor(ColorManager.textColor(7)); this.drawText("[Enter] Send [Esc] Leave", 0, lh * 2, this.innerWidth, "right"); this.resetTextColor(); } } //========================================================================= // Scene_Map - inject input window //========================================================================= const _origCreate = Scene_Map.prototype.createAllWindows; Scene_Map.prototype.createAllWindows = function () { _origCreate.call(this); const ww = Graphics.boxWidth * 0.82; const wh = this.calcWindowHeight(3, false); const rect = new Rectangle((Graphics.boxWidth - ww) / 2, Graphics.boxHeight - wh - 8, ww, wh); this._npcInputWindow = new Window_NpcInput(rect); this.addWindow(this._npcInputWindow); }; const getInputWin = () => SceneManager._scene?._npcInputWindow || null; //========================================================================= // Plugin Commands //========================================================================= // --- Test API Connection (debug) --- PluginManager.registerCommand(PLUGIN_NAME, "testApiKey", function () { const interp = this; interp._waitCount = 9999; (async () => { const key = Config.apiKey; if (!key) { $gameMessage.clear(); $gameMessage.add("[GroqNPC] API Key is EMPTY."); $gameMessage.add(`Raw param value: "${params["apiKey"]}"`); interp._waitCount = 0; return; } try { const res = await fetch(API_URL, { method : "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` }, body : JSON.stringify({ model: Config.model, max_tokens: 5, stream: false, messages: [{ role: "user", content: "Say OK" }] }), }); const data = await res.json(); $gameMessage.clear(); if (res.ok) { $gameMessage.add("[GroqNPC] API OK! Key works."); $gameMessage.add(`Model: ${Config.model}`); } else { $gameMessage.add(`[GroqNPC] API ERROR ${res.status}`); $gameMessage.add(data?.error?.message || "Unknown error"); } } catch (e) { $gameMessage.clear(); $gameMessage.add("[GroqNPC] Network error:"); $gameMessage.add(e.message); } interp._waitCount = 0; })(); }); PluginManager.registerCommand(PLUGIN_NAME, "defineQuest", function (args) { const id = String(args.questId || "").trim(); if (!id) { warn("defineQuest: questId is empty."); return; } const switchId = Number(args.questSwitch || 0); const varId = Number(args.questVariable || 0); _quests[id] = { id, title : String(args.title || id), description : String(args.description || ""), objectives : String(args.objectives || "").split("|").map(s => s.trim()).filter(Boolean), currentObjective: 0, status : "active", npcMembers : String(args.npcMembers || "").split(",").map(s => s.trim()).filter(Boolean), questSwitch : switchId, questVariable : varId, }; if (switchId > 0) $gameSwitches.setValue(switchId, true); if (varId > 0) $gameVariables.setValue(varId, 0); log("Quest defined:", id, _quests[id]); }); PluginManager.registerCommand(PLUGIN_NAME, "advanceObjective", function (args) { advanceQuestObjective(String(args.questId || "").trim()); }); PluginManager.registerCommand(PLUGIN_NAME, "completeQuest", function (args) { const q = getQuest(String(args.questId || "").trim()); if (!q) { warn("completeQuest: quest not found"); return; } q.status = "completed"; q.currentObjective = q.objectives.length - 1; if (q.questSwitch > 0) $gameSwitches.setValue(q.questSwitch, false); log("Quest completed:", q.id); }); PluginManager.registerCommand(PLUGIN_NAME, "failQuest", function (args) { const q = getQuest(String(args.questId || "").trim()); if (!q) { warn("failQuest: quest not found"); return; } q.status = "failed"; if (q.questSwitch > 0) $gameSwitches.setValue(q.questSwitch, false); log("Quest failed:", q.id); }); PluginManager.registerCommand(PLUGIN_NAME, "trainPersona", function (args) { const k = memKey($gameMap.mapId(), this._eventId); _trainedPersonas[k] = { name : String(args.name || "NPC"), persona : String(args.personality || "You are a villager."), role : String(args.role || "none"), questId : String(args.questId || ""), guardrails: String(args.guardrails || ""), fallback : String(args.fallbackMessage || "I cannot speak of such things."), maxTokens : Number(args.maxTokens || 120), }; log("Persona trained:", _trainedPersonas[k].name, "| role:", _trainedPersonas[k].role, "| quest:", _trainedPersonas[k].questId); }); PluginManager.registerCommand(PLUGIN_NAME, "startDialogue", function () { const interp = this; const event = $gameMap.event(interp._eventId); const mapId = $gameMap.mapId(); const eventId = interp._eventId; if (!event) { warn("startDialogue: event not found. ID:", eventId); return; } const k = memKey(mapId, eventId); const tags = _trainedPersonas[k] || parseNotetags(event); const sys = buildSysPrompt(tags); interp._waitCount = 999999; (async () => { let active = true; let currentPrompt = Config.defaultPrompt; while (active) { const input = await getPlayerInput(currentPrompt); if (input === null) break; pushMem(mapId, eventId, "user", input); let raw = ""; try { // +40 tokens headroom for DIALOGUE:/PROMPT: structure overhead raw = await callGroqStream(sys, getMem(mapId, eventId), tags.maxTokens + 40); } catch (e) { lerr("API error:", e.message); raw = `DIALOGUE: [Connection error. Try again.]\nPROMPT: ${Config.defaultPrompt}`; } const { dialogue, prompt } = parseResponse(raw); currentPrompt = prompt; pushMem(mapId, eventId, "assistant", dialogue); await showMsg(convertTags(dialogue)); active = await promptContinue(); } interp._waitCount = 0; })(); }); PluginManager.registerCommand(PLUGIN_NAME, "evaluateEnemyBehavior", function (args) { const interp = this; const enemyName = String(args.enemyName || "Enemy"); const enemyDesc = String(args.enemyDescription || ""); const questId = String(args.questId || ""); const baseSwitch = Number(args.behaviorSwitchBase || 1); const hpVarId = Number(args.playerHpPercent || 0); const playerHp = hpVarId > 0 ? $gameVariables.value(hpVarId) : 100; interp._waitCount = 9999; (async () => { let idx = 0; try { idx = await evaluateEnemyBehavior(enemyName, enemyDesc, questId, playerHp); } catch (e) { lerr("Enemy eval error:", e.message); idx = 0; } for (let i = 0; i < ENEMY_BEHAVIORS.length; i++) { $gameSwitches.setValue(baseSwitch + i, i === idx); } log(`Enemy "${enemyName}" -> ${ENEMY_BEHAVIORS[idx]} (Switch ${baseSwitch + idx} = ON)`); interp._waitCount = 0; })(); }); PluginManager.registerCommand(PLUGIN_NAME, "setContextVar", function (args) { _ctxVars.push({ label : String(args.label || ""), variableId : Number(args.variableId || 0), switchId : Number(args.switchId || 0), }); }); PluginManager.registerCommand(PLUGIN_NAME, "clearMemory", function () { clearMem($gameMap.mapId(), this._eventId); log("Memory cleared for event:", this._eventId); }); PluginManager.registerCommand(PLUGIN_NAME, "clearAllMemory", function () { Object.keys(_npcMemory).forEach(k => { _npcMemory[k] = []; }); log("All NPC memory cleared."); }); //========================================================================= // Async helpers //========================================================================= function getPlayerInput(prompt) { return new Promise(res => { const w = getInputWin(); if (!w) { res(null); return; } w.activate(prompt, text => res(text), () => res(null)); }); } // Wraps text into lines of maxChars, then groups lines into pages of maxLines. // MZ message window shows ~4 lines, ~50 chars per line with default font. function wrapTextToPages(text, maxChars, maxLines) { // Step 1: word-wrap into lines const words = text.split(" "); const lines = []; let cur = ""; for (const word of words) { const test = cur ? cur + " " + word : word; if (test.length > maxChars) { if (cur) lines.push(cur); cur = word; } else { cur = test; } } if (cur) lines.push(cur); // Step 2: group lines into pages const pages = []; for (let i = 0; i < lines.length; i += maxLines) { pages.push(lines.slice(i, i + maxLines).join("\n")); } return pages.length ? pages : [text]; } function showMsg(text) { return new Promise(res => { $gameMessage.clear(); // 50 chars per line, 4 lines per page - adjust if you use a larger font const pages = wrapTextToPages(text, 50, 4); pages.forEach(p => $gameMessage.add(p)); const t = setInterval(() => { if (!$gameMessage.isBusy()) { clearInterval(t); res(); } }, 100); }); } function promptContinue() { return new Promise(res => { $gameMessage.clear(); $gameMessage.setChoices(["Continue", "Leave"], 0, 1); $gameMessage.setChoiceCallback(i => res(i === 0)); }); } // splitChunks removed in v2.1 - MZ handles word wrap natively //========================================================================= // Save / Load //========================================================================= const _origMake = DataManager.makeSaveContents; DataManager.makeSaveContents = function () { const c = _origMake.call(this); c.groqNpcMemory = _npcMemory; c.groqPersonas = _trainedPersonas; c.groqQuests = _quests; c.groqCtxVars = _ctxVars.slice(); return c; }; const _origExtract = DataManager.extractSaveContents; DataManager.extractSaveContents = function (c) { _origExtract.call(this, c); if (c.groqNpcMemory) Object.assign(_npcMemory, c.groqNpcMemory); if (c.groqPersonas) Object.assign(_trainedPersonas, c.groqPersonas); if (c.groqQuests) Object.assign(_quests, c.groqQuests); if (c.groqCtxVars) c.groqCtxVars.forEach(v => { if (!_ctxVars.find(x => x.label === v.label)) _ctxVars.push(v); }); }; log(`v2.2 loaded | model:${Config.model} | timeout:${Config.streamTimeout/1000}s | fmt:${Config.enableFmt} | debug:${Config.debugMode}`); log("API key present:", Config.apiKey.length > 0, "| length:", Config.apiKey.length); log("RAW params.apiKey:", JSON.stringify(params["apiKey"])); })(); 复制代码
本贴来自国际rpgmaker官方论坛作者:Rpx处,因国际论坛即将永久关站,为了存档多年珍贵资料,署名转载到本论坛存档,由于官方帖子为英文原帖,需要中文翻译请点击论坛顶部切换语言为中文就可以将帖子翻译成中文浏览,方便大家随时查看,原文地址:
https://forums.rpgmakerweb.com/threads/ai-dialogue.183612/
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
x