Downloads containing npcBunniesEditorSerial.asc

Downloads
Name Author Game Mode Rating
TSF with JJ2+ Only: NpcBunnies Cranky Mutator N/A Download file

File preview

namespace npcBunniesEditorSerialization
{
const int NDE_SYNC_CHUNK_SIZE = 96;

void NdeSendNpcDesignerStateToServer(const string &in text)
{
	NdeSendNpcDesignerState(text, 0);
}

void NdeBroadcastNpcDesignerState(const string &in text, int clientID)
{
	if(!jjIsServer) return;
	NdeSendNpcDesignerState(text, clientID);
}

void NdeSendNpcDesignerState(const string &in text, int clientID)
{
	string encoded = NdePacketEncode(text);
	jjSTREAM begin;
	begin.push("npcEditorBegin");
	if(clientID == 0) jjSendPacket(begin);
	else jjSendPacket(begin, clientID);
	for(int i = 0; i < int(encoded.length); i += NDE_SYNC_CHUNK_SIZE)
	{
		jjSTREAM chunkPacket;
		chunkPacket.push("npcEditorChunk");
		chunkPacket.push(encoded.substr(i, npcBunniesEditorCore::NdeMin(NDE_SYNC_CHUNK_SIZE, int(encoded.length) - i)));
		if(clientID == 0) jjSendPacket(chunkPacket);
		else jjSendPacket(chunkPacket, clientID);
	}
	jjSTREAM end;
	end.push("npcEditorEnd");
	if(clientID == 0) jjSendPacket(end);
	else jjSendPacket(end, clientID);
}

string NdeHexDigit(int value)
{
	return value < 10 ? "" + value : (value == 10 ? "A" : value == 11 ? "B" : value == 12 ? "C" : value == 13 ? "D" : value == 14 ? "E" : "F");
}

int NdeHexValue(uint8 c)
{
	if(c >= 48 && c <= 57) return c - 48;
	if(c >= 65 && c <= 70) return c - 55;
	if(c >= 97 && c <= 102) return c - 87;
	return 0;
}

string NdePacketEncode(const string &in text)
{
	string encoded = "";
	for(uint i = 0; i < text.length; i++)
	{
		uint8 c = text[i];
		encoded += NdeHexDigit(c >> 4);
		encoded += NdeHexDigit(c & 15);
	}
	return encoded;
}

string NdePacketDecode(const string &in encoded)
{
	string text = "";
	for(uint i = 0; i + 1 < encoded.length; i += 2)
	{
		uint8 c = uint8((NdeHexValue(encoded[i]) << 4) | NdeHexValue(encoded[i + 1]));
		text += " ";
		text[text.length - 1] = c;
	}
	return text;
}
void NdeClearNpcRuntimeState()
{
	for(uint i = 0; i < npcBunniesCore::npcBunnies.length; i++)
	{
		if(npcBunniesCore::npcBunnies[i].objectID >= 0) jjObjects[npcBunniesCore::npcBunnies[i].objectID].delete();
	}
	npcBunniesCore::npcBunnies.resize(0);
	npcBunniesCore::npcObjectIds.deleteAll();
	npcBunniesMovement::npcFollowWaypoints.resize(0);
	npcBunniesMovement::previousNpcX.resize(0);
	npcBunniesMovement::previousNpcY.resize(0);
	npcBunniesMovement::lastNpcPositionSyncTick.resize(0);
	npcBunniesMovement::lastNpcSyncedX.resize(0);
	npcBunniesMovement::lastNpcSyncedY.resize(0);
}

void NdeApplyNpcDesignerState(const string &in text, bool saveFile)
{
	NdeClearNpcRuntimeState();
	array<string> tokens = npcBunniesParser::TokenizeNpcJson(npcBunniesParser::StripNpcJsonComments(text));
	array<int> pos = {0};
	npcBunniesParser::ParseNpcJsonRoot(tokens, pos);
	npcBunniesMovement::EnsureNpcRuntimeArrays();
	if(saveFile) NdeSaveNpcFileText(text);
}
/// <summary>
/// Builds the NPC data filename for the current level.
/// </summary>
string NdeNpcFileName()
{
	string baseName = jjLevelFileName;
	int dot = int(baseName.findLast("."));
	if(dot > 0) baseName = baseName.substr(0, dot);
	return baseName + "_npc.asdat";
}

/// <summary>
/// Creates a one-time backup of the current level NPC data file.
/// </summary>
void NdeBackupNpcFile()
{
	if(npcBunniesEditorCore::NpcDesignerBackedUp) return;
	npcBunniesEditorCore::NpcDesignerBackedUp = true;
	string filename = NdeNpcFileName();
	jjSTREAM load(filename);
	if(load.isEmpty()) return;
	jjSTREAM save;
	string line;
	while(load.getLine(line)) save.write(line + "\n");
	string baseName = jjLevelFileName;
	int dot = int(baseName.findLast("."));
	if(dot > 0) baseName = baseName.substr(0, dot);
	save.save(baseName + "_npc - BACKUP.asdat");
}

/// <summary>
/// Builds the current NPC data file text.
/// </summary>
string NdeBuildNpcFileText()
{
	string text = NdeCollectNpcFileComments();
	text += "{\n";
	text += "\t\"LoadBigAnimations\": " + (npcBunniesCore::LOAD_BIG_NPC_ANIMATIONS ? "true" : "false") + ",\n";
	text += "\t\"LoadAllMonsterAnimations\": " + (npcBunniesCore::LOAD_ALL_MONSTER_ANIMATIONS ? "true" : "false") + ",\n";
	text += npcBunniesEditorPreview::NdeAdditionalMonsterAnimationsJson();
	text += "	\"NPCS\": [\n";
	for(uint i = 0; i < npcBunniesCore::npcBunnies.length; i++)
	{
		text += NdeNpcJson(npcBunniesCore::npcBunnies[i], i + 1 == npcBunniesCore::npcBunnies.length);
	}
	text += "	]\n";
	text += "}\n";
	return text;
}

/// <summary>
/// Writes the current NPC data to the level NPC file.
/// </summary>
void NdeSaveNpcFile()
{
	if(!npcBunniesCore::LocalPlayerHasNpcBunniesAdminAccess()) return;
	string text = NdeBuildNpcFileText();
	if(jjIsServer)
	{
		NdeSaveNpcFileText(text);
		NdeBroadcastNpcDesignerState(text, 0);
	}
	else
	{
		NdeSendNpcDesignerStateToServer(text);
	}
}

void NdeSaveNpcFileText(const string &in text)
{
	NdeBackupNpcFile();
	jjSTREAM save;
	save.write(text);
	save.save(NdeNpcFileName());
}

string NdeAccessoryJsonValue(int frame)
{
	return frame < 0 ? "No" : "" + frame;
}

/// <summary>
/// Writes npc data to the NPC file stream.
/// </summary>
string NdeNpcJson(npcBunniesCore::npcBunny &in npc, bool last)
{
	string text = "		{\n";
	text += "			\"NPC\": \"" + NdeJson(npc.name) + "\",\n";
	text += "			\"Type\": \"" + npcBunniesEditorPreview::NdeNpcTypeFileName(npc) + "\",\n";
	text += "			\"Vars\": {\n";
	text += "				\"PosX\": " + npc.xTile + ",\n";
	text += "				\"PosY\": " + npc.yTile + ",\n";
	text += "				\"Direction\": " + npc.direction + ",\n";
	text += "				\"Size\": \"" + npcBunniesEditorActions::NdeNpcSizeName(npc.sizeMode) + "\",\n";
	text += "				\"Speed\": " + npcBunniesEditorActions::NdeNpcSpeedName(npc.moveSpeed) + ",\n";
	text += "				\"FurColor1\": " + npc.fur.c1 + ",\n";
	text += "				\"FurColor2\": " + npc.fur.c2 + ",\n";
	text += "				\"FurColor3\": " + npc.fur.c3 + ",\n";
	text += "				\"FurColor4\": " + npc.fur.c4 + ",\n";
	text += "				\"Hat\": \"" + NdeAccessoryJsonValue(npc.hatFrame) + "\",\n";
	text += "				\"Scarf\": \"" + NdeAccessoryJsonValue(npc.scarfFrame) + "\",\n";
	text += "				\"Glasses\": \"" + NdeAccessoryJsonValue(npc.glassesFrame) + "\",\n";
	text += "				\"Moveable\": " + (npc.moveable ? "true" : "false") + ",\n";
	text += "				\"Pushable\": " + (npc.pushable ? "true" : "false") + ",\n";
	text += "				\"Fighting\": { \"Enabled\": " + (npc.fightingAvailable ? "true" : "false") + ", \"Style\": \"" + npcBunniesFighting::NpcFightingStyleName(npc.fightingStyle) + "\" },\n";
	text += "				\"TraceMode\": \"" + npcBunniesCore::NpcTraceModeName(npc.traceMode) + "\",\n";
	text += "				\"TraceColor\": " + npc.traceColor + "\n";
	text += "			},\n";
	if(npc.dialogueSets.length > 0) text += NdeDialogueSetsJson(npc);
	if(npc.shopItems.length > 0) text += NdeShopJson(npc);
	if(npc.guideEnabled || npc.guidePaths.length > 0) text += NdeGuideJson(npc);
	text += "			\"GuideEnabled\": " + (npc.guideEnabled ? "true" : "false") + "\n";
	text += last ? "		}\n" : "		},\n";
	return text;
}

string NdeDialogueSetsJson(npcBunniesCore::npcBunny &in npc)
{
	string text = "			\"DialogueSets\": [\n";
	for(uint i = 0; i < npc.dialogueSets.length; i++)
	{
		npcBunniesDialogue::DialogueSet@ set = npc.dialogueSets[i];
		text += "				{\n";
		text += "					\"Type\": \"" + npcBunniesEditorPreview::NdeDialogueTypeName(set.choicesAnswerMethod) + "\",\n";
		text += "					\"Speaker\": " + (set.speakerNpcIndex < 0 ? "\"Default\"" : "" + (set.speakerNpcIndex + 1)) + ",\n";
		text += "					\"GoToSet\": " + set.goToSet + ",\n";
		if(set.choicesAnswerMethod == npcBunniesCore::DialogueAction::Normal)
		{
			text += "					\"AutoContinue\": \"" + npcBunniesEditorActions::NdeDialogueAutoContinueName(npc, set) + "\",\n";
			text += "					\"AutoContinueSet\": " + set.autoContinueSet + ",\n";
		}
		text += "					\"Frame\": " + set.bigFrame + ",\n";
		text += "					\"QuestInProgressLine\": \"" + NdeJson(set.questInProgressLine) + "\",\n";
		text += "					\"QuestFinishedLine\": \"" + NdeJson(set.questCompletedLine) + "\",\n";
		if(set.choicesAnswerMethod == npcBunniesCore::DialogueAction::Quest) text += "					\"RespawnMonsters\": " + (set.respawnMonsters ? "true" : "false") + ",\n";
		text += "					\"DialogueLines\": [\n";
		for(uint j = 0; j < set.dialogue.length; j++) text += "						{ \"Text\": \"" + NdeJson(set.dialogue[j].text) + "\" }" + (j + 1 == set.dialogue.length ? "\n" : ",\n");
		text += "					]";
		if(set.choices.length > 0 && (set.choicesAnswerMethod == npcBunniesCore::DialogueAction::Answer || set.choicesAnswerMethod == npcBunniesCore::DialogueAction::Choice))
		{
			text += ",\n					\"" + (set.choicesAnswerMethod == npcBunniesCore::DialogueAction::Answer ? "TextAnswers" : "Choices") + "\": [\n";
			for(uint j = 0; j < set.choices.length; j++) text += "						{ \"Text\": \"" + NdeJson(set.choices[j].choiceText) + "\", \"GoToSet\": " + set.choices[j].goToSet + " }" + (j + 1 == set.choices.length ? "\n" : ",\n");
			text += "					]";
		}
		if(set.quests.length > 0) text += NdeQuestsJson(set);
		if(set.rewards.length > 0) text += NdeRewardsJson(set);
		text += "\n				}" + (i + 1 == npc.dialogueSets.length ? "\n" : ",\n");
	}
	text += "			],\n";
	return text;
}

string NdeQuestsJson(npcBunniesDialogue::DialogueSet@ set)
{
	string text = ",\n					\"Quests\": [\n";
	for(uint i = 0; i < set.quests.length; i++) text += "						" + NdeQuestJson(set.quests[i]) + (i + 1 == set.quests.length ? "\n" : ",\n");
	text += "					]";
	return text;
}

string NdeRewardsJson(npcBunniesDialogue::DialogueSet@ set)
{
	string text = ",\n					\"Rewards\": [\n";
	for(uint i = 0; i < set.rewards.length; i++) text += "						" + NdeRewardJson(set.rewards[i]) + (i + 1 == set.rewards.length ? "\n" : ",\n");
	text += "					]";
	return text;
}

string NdeShopJson(npcBunniesCore::npcBunny &in npc)
{
	string text = "			\"Shop\": {\n				\"Columns\": " + npc.shopColumns + ",\n				\"Items\": [\n";
	for(uint i = 0; i < npc.shopItems.length; i++)
	{
		npcBunniesShop::NpcShopItem@ item = npc.shopItems[i];
		text += "					{ \"Name\": \"" + NdeJson(item.name) + "\", \"Item\": " + NdeRewardJson(item.item) + ", \"PriceCoins\": " + item.priceCoins + ", \"PriceGems\": " + item.priceGems + ", \"GemType\": \"Red\", \"Stock\": \"" + (item.stock < 0 ? "inf" : "" + item.stock) + "\" }" + (i + 1 == npc.shopItems.length ? "\n" : ",\n");
	}
	text += "				]\n			},\n";
	return text;
}

string NdeGuideJson(npcBunniesCore::npcBunny &in npc)
{
	string text = "			\"Guide\": {\n				\"Enabled\": " + (npc.guideEnabled ? "true" : "false") + ",\n				\"Paths\": [\n";
	for(uint i = 0; i < npc.guidePaths.length; i++)
	{
		text += "					{ \"Type\": \"" + (npc.guidePaths[i].isTeleport ? "Teleport" : "Path") + "\", \"Line\": \"" + NdeJson(npc.guidePaths[i].line) + "\"";
		if(npc.guidePaths[i].isTeleport)
			text += ", \"X\": " + npc.guidePaths[i].teleportX + ", \"Y\": " + npc.guidePaths[i].teleportY + ", \"Direction\": " + npc.guidePaths[i].teleportDirection;
		else
		{
			text += ", \"Path\": [";
			for(uint j = 0; j < npc.guidePaths[i].steps.length; j++) text += "[" + npc.guidePaths[i].steps[j].x + ", " + npc.guidePaths[i].steps[j].y + "]" + (j + 1 == npc.guidePaths[i].steps.length ? "" : ", ");
			text += "]";
		}
		text += " }" + (i + 1 == npc.guidePaths.length ? "\n" : ",\n");
	}
	text += "				],\n";
	text += "				\"EndLine\": \"" + NdeJson(npc.guideEndLine) + "\",\n";
	text += "				\"AlreadyAtDestinationLine\": \"" + NdeJson(npc.guideAlreadyAtDestinationLine) + "\"\n";
	text += "			},\n";
	return text;
}
void NdeWriteNpc(jjSTREAM &inout save, npcBunniesCore::npcBunny &in npc, bool last)
{
	save.write("\t\t{\n");
	save.write("\t\t\t\"NPC\": \"" + NdeJson(npc.name) + "\",\n");
	save.write("\t\t\t\"Type\": \"" + npcBunniesEditorPreview::NdeNpcTypeFileName(npc) + "\",\n");
	save.write("\t\t\t\"Vars\": {\n");
	save.write("\t\t\t\t\"PosX\": " + npc.xTile + ",\n");
	save.write("\t\t\t\t\"PosY\": " + npc.yTile + ",\n");
	save.write("\t\t\t\t\"Direction\": " + npc.direction + ",\n");
	save.write("\t\t\t\t\"Size\": \"" + npcBunniesEditorActions::NdeNpcSizeName(npc.sizeMode) + "\",\n");
	save.write("\t\t\t\t\"Speed\": " + npcBunniesEditorActions::NdeNpcSpeedName(npc.moveSpeed) + ",\n");
	save.write("\t\t\t\t\"FurColor1\": " + npc.fur.c1 + ",\n");
	save.write("\t\t\t\t\"FurColor2\": " + npc.fur.c2 + ",\n");
	save.write("\t\t\t\t\"FurColor3\": " + npc.fur.c3 + ",\n");
	save.write("\t\t\t\t\"FurColor4\": " + npc.fur.c4 + ",\n");
	save.write("\t\t\t\t\"Hat\": \"" + NdeAccessoryJsonValue(npc.hatFrame) + "\",\n");
	save.write("\t\t\t\t\"Scarf\": \"" + NdeAccessoryJsonValue(npc.scarfFrame) + "\",\n");
	save.write("\t\t\t\t\"Glasses\": \"" + NdeAccessoryJsonValue(npc.glassesFrame) + "\",\n");
	save.write("\t\t\t\t\"Moveable\": " + (npc.moveable ? "true" : "false") + ",\n");
	save.write("\t\t\t\t\"Pushable\": " + (npc.pushable ? "true" : "false") + ",\n");
	save.write("\t\t\t\t\"Fighting\": { \"Enabled\": " + (npc.fightingAvailable ? "true" : "false") + ", \"Style\": \"" + npcBunniesFighting::NpcFightingStyleName(npc.fightingStyle) + "\" },\n");
	save.write("\t\t\t\t\"TraceMode\": \"" + npcBunniesCore::NpcTraceModeName(npc.traceMode) + "\",\n");
	save.write("\t\t\t\t\"TraceColor\": " + npc.traceColor + "\n");
	save.write("\t\t\t},\n");
	if(npc.dialogueSets.length > 0) NdeWriteDialogueSets(save, npc);
	if(npc.shopItems.length > 0) NdeWriteShop(save, npc);
	if(npc.guideEnabled || npc.guidePaths.length > 0) NdeWriteGuide(save, npc);
	save.write("\t\t\t\"GuideEnabled\": " + (npc.guideEnabled ? "true" : "false") + "\n");
	save.write(last ? "\t\t}\n" : "\t\t},\n");
}

/// <summary>
/// Writes dialogue sets data to the NPC file stream.
/// </summary>
void NdeWriteDialogueSets(jjSTREAM &inout save, npcBunniesCore::npcBunny &in npc)
{
	save.write("\t\t\t\"DialogueSets\": [\n");
	for(uint i = 0; i < npc.dialogueSets.length; i++)
	{
		npcBunniesDialogue::DialogueSet@ set = npc.dialogueSets[i];
		save.write("\t\t\t\t{\n");
		save.write("\t\t\t\t\t\"Type\": \"" + npcBunniesEditorPreview::NdeDialogueTypeName(set.choicesAnswerMethod) + "\",\n");
		save.write("\t\t\t\t\t\"Speaker\": " + (set.speakerNpcIndex < 0 ? "\"Default\"" : "" + (set.speakerNpcIndex + 1)) + ",\n");
		save.write("\t\t\t\t\t\"GoToSet\": " + set.goToSet + ",\n");
		if(set.choicesAnswerMethod == npcBunniesCore::DialogueAction::Normal)
		{
			save.write("\t\t\t\t\t\"AutoContinue\": \"" + npcBunniesEditorActions::NdeDialogueAutoContinueName(npc, set) + "\",\n");
			save.write("\t\t\t\t\t\"AutoContinueSet\": " + set.autoContinueSet + ",\n");
		}
		save.write("\t\t\t\t\t\"Frame\": " + set.bigFrame + ",\n");
		save.write("\t\t\t\t\t\"QuestInProgressLine\": \"" + NdeJson(set.questInProgressLine) + "\",\n");
		save.write("\t\t\t\t\t\"QuestFinishedLine\": \"" + NdeJson(set.questCompletedLine) + "\",\n");
		if(set.choicesAnswerMethod == npcBunniesCore::DialogueAction::Quest) save.write("\t\t\t\t\t\"RespawnMonsters\": " + (set.respawnMonsters ? "true" : "false") + ",\n");
		save.write("\t\t\t\t\t\"DialogueLines\": [\n");
		for(uint j = 0; j < set.dialogue.length; j++)
			save.write("\t\t\t\t\t\t{ \"Text\": \"" + NdeJson(set.dialogue[j].text) + "\" }" + (j + 1 == set.dialogue.length ? "\n" : ",\n"));
		save.write("\t\t\t\t\t]");
		if(set.choices.length > 0 && (set.choicesAnswerMethod == npcBunniesCore::DialogueAction::Answer || set.choicesAnswerMethod == npcBunniesCore::DialogueAction::Choice))
		{
			save.write(",\n\t\t\t\t\t\"" + (set.choicesAnswerMethod == npcBunniesCore::DialogueAction::Answer ? "TextAnswers" : "Choices") + "\": [\n");
			for(uint j = 0; j < set.choices.length; j++)
				save.write("\t\t\t\t\t\t{ \"Text\": \"" + NdeJson(set.choices[j].choiceText) + "\", \"GoToSet\": " + set.choices[j].goToSet + " }" + (j + 1 == set.choices.length ? "\n" : ",\n"));
			save.write("\t\t\t\t\t]");
		}
		if(set.quests.length > 0) NdeWriteQuests(save, set);
		if(set.rewards.length > 0) NdeWriteRewards(save, set);
		save.write("\n\t\t\t\t}" + (i + 1 == npc.dialogueSets.length ? "\n" : ",\n"));
	}
	save.write("\t\t\t],\n");
}

/// <summary>
/// Writes quests data to the NPC file stream.
/// </summary>
void NdeWriteQuests(jjSTREAM &inout save, npcBunniesDialogue::DialogueSet@ set)
{
	save.write(",\n\t\t\t\t\t\"Quests\": [\n");
	for(uint i = 0; i < set.quests.length; i++)
		save.write("\t\t\t\t\t\t" + NdeQuestJson(set.quests[i]) + (i + 1 == set.quests.length ? "\n" : ",\n"));
	save.write("\t\t\t\t\t]");
}

/// <summary>
/// Writes rewards data to the NPC file stream.
/// </summary>
void NdeWriteRewards(jjSTREAM &inout save, npcBunniesDialogue::DialogueSet@ set)
{
	save.write(",\n\t\t\t\t\t\"Rewards\": [\n");
	for(uint i = 0; i < set.rewards.length; i++)
		save.write("\t\t\t\t\t\t" + NdeRewardJson(set.rewards[i]) + (i + 1 == set.rewards.length ? "\n" : ",\n"));
	save.write("\t\t\t\t\t]");
}

/// <summary>
/// Writes shop data to the NPC file stream.
/// </summary>
void NdeWriteShop(jjSTREAM &inout save, npcBunniesCore::npcBunny &in npc)
{
	save.write("\t\t\t\"Shop\": {\n\t\t\t\t\"Columns\": " + npc.shopColumns + ",\n\t\t\t\t\"Items\": [\n");
	for(uint i = 0; i < npc.shopItems.length; i++)
	{
		npcBunniesShop::NpcShopItem@ item = npc.shopItems[i];
		save.write("\t\t\t\t\t{ \"Name\": \"" + NdeJson(item.name) + "\", \"Item\": " + NdeRewardJson(item.item) + ", \"PriceCoins\": " + item.priceCoins + ", \"PriceGems\": " + item.priceGems + ", \"GemType\": \"Red\", \"Stock\": \"" + (item.stock < 0 ? "inf" : "" + item.stock) + "\" }" + (i + 1 == npc.shopItems.length ? "\n" : ",\n"));
	}
	save.write("\t\t\t\t]\n\t\t\t},\n");
}

/// <summary>
/// Writes guide data to the NPC file stream.
/// </summary>
void NdeWriteGuide(jjSTREAM &inout save, npcBunniesCore::npcBunny &in npc)
{
	save.write("\t\t\t\"Guide\": {\n\t\t\t\t\"Enabled\": " + (npc.guideEnabled ? "true" : "false") + ",\n\t\t\t\t\"Paths\": [\n");
	for(uint i = 0; i < npc.guidePaths.length; i++)
	{
		save.write("\t\t\t\t\t{ \"Type\": \"" + (npc.guidePaths[i].isTeleport ? "Teleport" : "Path") + "\", \"Line\": \"" + NdeJson(npc.guidePaths[i].line) + "\"");
		if(npc.guidePaths[i].isTeleport)
			save.write(", \"X\": " + npc.guidePaths[i].teleportX + ", \"Y\": " + npc.guidePaths[i].teleportY + ", \"Direction\": " + npc.guidePaths[i].teleportDirection);
		else
		{
			save.write(", \"Path\": [");
			for(uint j = 0; j < npc.guidePaths[i].steps.length; j++)
				save.write("[" + npc.guidePaths[i].steps[j].x + ", " + npc.guidePaths[i].steps[j].y + "]" + (j + 1 == npc.guidePaths[i].steps.length ? "" : ", "));
			save.write("]");
		}
		save.write(" }" + (i + 1 == npc.guidePaths.length ? "\n" : ",\n"));
	}
	save.write("\t\t\t\t],\n");
	save.write("\t\t\t\t\"EndLine\": \"" + NdeJson(npc.guideEndLine) + "\",\n");
	save.write("\t\t\t\t\"AlreadyAtDestinationLine\": \"" + NdeJson(npc.guideAlreadyAtDestinationLine) + "\"\n");
	save.write("\t\t\t},\n");
}

/// <summary>
/// Collects existing line and block comments so saving does not discard manual notes.
/// </summary>
string NdeCollectNpcFileComments()
{
	jjSTREAM load(NdeNpcFileName());
	if(load.isEmpty()) return "";
	string comments = "";
	string line;
	bool inBlock = false;
	bool inPreservedBlock = false;
	while(load.getLine(line))
	{
		if(line.findFirst("NPC Designer preserved comments from previous _npc.asdat") >= 0) { inPreservedBlock = true; continue; }
		if(inPreservedBlock)
		{
			if(line.findFirst("NPC Designer end preserved comments") >= 0) inPreservedBlock = false;
			else comments += line + "\n";
			continue;
		}
		if(inBlock)
		{
			int blockEnd = line.findFirst("*/");
			if(blockEnd >= 0)
			{
				comments += line.substr(0, blockEnd + 2) + "\n";
				inBlock = false;
			}
			else comments += line + "\n";
			continue;
		}
		int blockAt = line.findFirst("/*");
		int lineAt = line.findFirst("//");
		if(blockAt >= 0 && (lineAt < 0 || blockAt < lineAt))
		{
			int blockEnd = line.findFirst("*/");
			if(blockEnd >= blockAt) comments += line.substr(blockAt, blockEnd - blockAt + 2) + "\n";
			else
			{
				comments += line.substr(blockAt) + "\n";
				inBlock = true;
			}
		}
		else if(lineAt >= 0)
		{
			comments += line.substr(lineAt) + "\n";
		}
	}
	if(comments == "") return "";
	return "// NPC Designer preserved comments from previous _npc.asdat:\n" + comments + "// NPC Designer end preserved comments\n";
}

/// <summary>
/// Serializes a reward item as parser-friendly JSON text.
/// </summary>
string NdeRewardJson(npcBunniesQuests::NpcReward@ reward)
{
	string json = "{ \"RewardType\": \"" + NdeRewardTypeJsonName(reward) + "\", \"Amount\": " + reward.amount;
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardGems) json += ", \"GemType\": \"" + npcBunniesEditorPreview::NdeGemName(reward.gemType) + "\"";
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardAmmo || reward.rewardType == npcBunniesQuests::RewardType::RewardPowerup) json += ", \"Weapon\": \"" + npcBunniesShop::GetWeaponDisplayName(reward.weaponType) + "\"";
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardTeleport) json += ", \"X\": " + int(reward.x) + ", \"Y\": " + int(reward.y);
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardTrigger) json += ", \"Trigger\": " + reward.triggerId + ", \"TriggerState\": " + (reward.triggerState ? "true" : "false");
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardGuidePath) json += ", \"GuidePath\": " + (reward.guidePathIndex + 1);
	json += " }";
	return json;
}

/// <summary>
/// Serializes a quest item as parser-friendly JSON text.
/// </summary>
string NdeQuestJson(npcBunniesQuests::NpcQuest@ quest)
{
	string json = "{ \"QuestType\": \"" + NdeQuestTypeJsonName(quest) + "\", \"Amount\": " + quest.amount;
	if(quest.questType == npcBunniesQuests::QuestType::Gems) json += ", \"GemType\": \"" + npcBunniesEditorPreview::NdeGemName(quest.gemType) + "\"";
	if(quest.questType == npcBunniesQuests::QuestType::DefeatMonster) json += ", \"Monster\": \"" + npcBunniesEditorPreview::NdeDefeatMonsterName(quest.objectType) + "\", \"EventID\": " + quest.objectType;
	if(quest.questType == npcBunniesQuests::QuestType::DefeatBoss) json += ", \"Boss\": \"" + npcBunniesEditorPreview::NdeDefeatBossName(quest.objectType) + "\", \"EventID\": " + quest.objectType;
	json += " }";
	return json;
}

/// <summary>
/// Returns the canonical JSON QuestType value for a quest.
/// </summary>
string NdeQuestTypeJsonName(npcBunniesQuests::NpcQuest@ quest)
{
	if(quest.questType == npcBunniesQuests::QuestType::Gems) return "Gems";
	if(quest.questType == npcBunniesQuests::QuestType::DefeatMonster) return "DefeatMonster";
	if(quest.questType == npcBunniesQuests::QuestType::DefeatBoss) return "DefeatBoss";
	return "Coins";
}

/// <summary>
/// Returns the canonical JSON RewardType value for a reward.
/// </summary>
string NdeRewardTypeJsonName(npcBunniesQuests::NpcReward@ reward)
{
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardGems) return "Gems";
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardAmmo) return "Ammo";
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardPowerup) return "Powerup";
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardTeleport) return "Teleport";
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardMoveable) return "Moveable";
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardTrigger) return "Trigger";
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardFastFire) return "FastFire";
	if(reward.rewardType == npcBunniesQuests::RewardType::RewardGuidePath) return "GuidePath";
	return "Coins";
}

/// <summary>
/// Escapes a string for JSON output.
/// </summary>
string NdeJson(const string &in text)
{
	string escaped = "";
	for(uint i = 0; i < text.length; i++)
	{
		uint8 c = text[i];
		if(c == 34) escaped += "\\\"";
		else if(c == 92) escaped += "\\\\";
		else { escaped += " "; escaped[escaped.length - 1] = c; }
	}
	return escaped;
}


//----------------------------------------------------------------------------------------------------
// NPC DESIGNER HELP REGION
// Edit npcHelp.asc freely; NdeDrawHelp will wrap long lines and long words to the current screen.

}