tools: Add cutscene player / subtitle editor window (#1429)

* stash

* temp

* tools: subtitle tool works! just gotta fill out the db / polish UX

* tools: added configuration for every subtitle we have so far

* tools: add some colors to the editor, time for repl controls and make it run the code!

* tools: continuing polish of tool, getting very close

* tools: finished UX polish, just need to write deserializers

* tools: added deserializer for subtitle data

* tools: exported subtitle files, all data appears intact

* tools: more UX polish and test all the cutscenes, majority work

* assets: update subtitle files

* lint: formatting and cleanup

* lint: codacy lints
This commit is contained in:
Tyler Wilding
2022-06-11 11:32:27 -04:00
committed by GitHub
parent 8ba89dd95e
commit 9fdf0bbc2f
45 changed files with 4322 additions and 1676 deletions
+7 -4
View File
@@ -3,7 +3,9 @@ add_library(common
cross_os_debug/xdbg.cpp
cross_sockets/XSocket.cpp
cross_sockets/XSocketServer.cpp
cross_sockets/XSocketClient.cpp
custom_data/TFrag3Data.cpp
deserialization/subtitles/subtitles.cpp
dma/dma.cpp
dma/dma_copy.cpp
dma/gs.cpp
@@ -17,8 +19,11 @@ add_library(common
goos/Reader.cpp
goos/TextDB.cpp
goos/ReplUtils.cpp
math/geometry.cpp
log/log.cpp
math/geometry.cpp
nrepl/ReplClient.cpp
nrepl/ReplServer.cpp
serialization/subtitles/subtitles.cpp
type_system/defenum.cpp
type_system/deftype.cpp
type_system/state.cpp
@@ -43,9 +48,7 @@ add_library(common
util/print_float.cpp
util/FontUtils.cpp
util/FrameLimiter.cpp
util/image_loading.cpp
goos/Printer.cpp
goos/PrettyPrinter2.cpp)
util/image_loading.cpp)
target_link_libraries(common fmt lzokay replxx libzstd_static)
+8
View File
@@ -34,6 +34,14 @@ int open_socket(int af, int type, int protocol) {
#endif
}
int connect_socket(int socket, sockaddr* addr, int nameLen) {
int result = connect(socket, addr, nameLen);
if (result == -1) {
return -1;
}
return result;
}
#ifdef __linux
int accept_socket(int socket, sockaddr* addr, socklen_t* addrLen) {
return accept(socket, addr, addrLen);
+1
View File
@@ -25,6 +25,7 @@ const int TCP_SOCKET_LEVEL = IPPROTO_TCP;
#endif
int open_socket(int af, int type, int protocol);
int connect_socket(int socket, sockaddr* addr, int nameLen);
#ifdef __linux
int accept_socket(int socket, sockaddr* addr, socklen_t* addrLen);
int select_and_accept_socket(int socket, sockaddr* addr, socklen_t* addrLen, int microSeconds);
+52
View File
@@ -0,0 +1,52 @@
#include "XSocketClient.h"
#include "common/cross_sockets/XSocket.h"
#include <string>
#ifdef _WIN32
#define NOMINMAX
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <WinSock2.h>
#include <WS2tcpip.h>
#endif
#include "common/nrepl/ReplServer.h"
#include "third-party/fmt/core.h"
XSocketClient::XSocketClient(int _tcp_port) {
tcp_port = _tcp_port;
}
XSocketClient::~XSocketClient() {
disconnect();
client_socket = -1;
}
void XSocketClient::disconnect() {
close_socket(client_socket);
client_socket = -1;
}
bool XSocketClient::connect() {
// Open Socket
client_socket = open_socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (client_socket < 0) {
// TODO - log
disconnect();
return false;
}
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
addr.sin_port = htons(tcp_port);
// Connect to server
int result = connect_socket(client_socket, (sockaddr*)&addr, sizeof(addr));
if (result == -1) {
// TODO - log and close
disconnect();
return false;
}
return true;
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "common/cross_sockets/XSocket.h"
#include <thread>
#include "common/common_types.h"
#include <functional>
#include <mutex>
/// @brief A cross platform generic socket client implementation
class XSocketClient {
public:
XSocketClient(int _tcp_port);
~XSocketClient();
XSocketClient(const XSocketClient&) = delete;
XSocketClient& operator=(const XSocketClient&) = delete;
bool connect();
void disconnect();
bool is_connected() { return client_socket != -1; }
protected:
int tcp_port;
struct sockaddr_in addr = {};
int client_socket = -1;
};
@@ -0,0 +1,82 @@
#include "subtitles.h"
#include <algorithm>
#include "third-party/fmt/core.h"
#include "third-party/fmt/ranges.h"
#include <regex>
#include "common/util/FileUtil.h"
#include "third-party/json.hpp"
bool write_subtitle_db_to_files(const GameSubtitleDB& db) {
// Write the subtitles out
std::vector<int> completed_banks = {};
for (const auto& [id, bank] : db.m_banks) {
// If we've done the bank before, skip it
auto it = find(completed_banks.begin(), completed_banks.end(), bank->m_lang_id);
if (it != completed_banks.end()) {
continue;
}
// Check to see if this bank is shared by any other, if so do it at the same time
// and skip it
// This is basically just to deal with US/UK english in a not so hacky way
std::vector<int> banks = {};
for (const auto& [_id, _bank] : db.m_banks) {
if (_bank->file_path == bank->file_path) {
banks.push_back(_bank->m_lang_id);
completed_banks.push_back(_bank->m_lang_id);
}
}
std::string file_contents = "";
file_contents += fmt::format("(language-id {})\n", fmt::join(banks, " "));
for (const auto& group_name : db.m_subtitle_groups->m_group_order) {
file_contents +=
fmt::format("\n;; -----------------\n;; {}\n;; -----------------\n", group_name);
for (const auto& [scene_name, scene_info] : bank->m_scenes) {
if (scene_info.m_sorting_group != group_name) {
continue;
}
file_contents += fmt::format("\n(\"{}\"", scene_name);
if (scene_info.m_kind == SubtitleSceneKind::Hint) {
file_contents += " :hint 0";
} else if (scene_info.m_kind == SubtitleSceneKind::HintNamed) {
file_contents += fmt::format(" :hint #x{0:x}", scene_info.m_id);
}
file_contents += "\n";
for (const auto& line : scene_info.m_lines) {
// Clear screen entries
if (line.line_utf8.empty()) {
file_contents += fmt::format(" ({})\n", line.frame);
} else {
file_contents += fmt::format(" ({}", line.frame);
if (line.offscreen && scene_info.m_kind == SubtitleSceneKind::Movie) {
file_contents += " :offscreen";
}
file_contents += fmt::format(" \"{}\"", line.speaker_utf8);
// escape quotes
std::string temp = line.line_utf8;
temp = std::regex_replace(temp, std::regex("\""), "\\\"");
file_contents += fmt::format(" \"{}\")\n", temp);
}
}
file_contents += " )\n";
}
}
// Commit it to the file
std::string full_path =
(file_util::get_jak_project_dir() / std::filesystem::path(bank->file_path)).string();
file_util::write_text_file(full_path, file_contents);
}
// Write the subtitle group info out
nlohmann::json json(db.m_subtitle_groups->m_groups);
json[db.m_subtitle_groups->group_order_key] = nlohmann::json(db.m_subtitle_groups->m_group_order);
std::string file_path = (file_util::get_jak_project_dir() / "game" / "assets" / "jak1" /
"subtitle" / "subtitle-groups.json")
.string();
file_util::write_text_file(file_path, json.dump(2));
return true;
}
@@ -0,0 +1,5 @@
#pragma once
#include "common/serialization/subtitles/subtitles.h"
bool write_subtitle_db_to_files(const GameSubtitleDB& db);
+34
View File
@@ -0,0 +1,34 @@
#include "ReplClient.h"
#include "common/cross_sockets/XSocket.h"
#include "third-party/fmt/core.h"
#include "common/versions.h"
#ifdef _WIN32
#define NOMINMAX
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <WinSock2.h>
#include <WS2tcpip.h>
#endif
void ReplClient::eval(std::string form) {
if (!is_connected()) {
return;
}
// TODO - split this up into two writes
u32 dataLength = form.length();
ReplServerHeader header = {dataLength, ReplServerMessageType::EVAL};
auto const ptr = reinterpret_cast<char*>(&header);
std::vector<char> buffer(ptr, ptr + sizeof header);
buffer.insert(buffer.end(), form.begin(), form.end());
int result = write_to_socket(client_socket, buffer.data(), buffer.size());
if (result == -1) {
// TODO - log
disconnect();
}
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include "common/cross_sockets/XSocketClient.h"
#include "ReplServer.h"
class ReplClient : public XSocketClient {
public:
using XSocketClient::XSocketClient;
virtual ~ReplClient() = default;
ReplClient& operator=(const ReplClient&) { return *this; }
// TODO - just void for now :(
void eval(std::string form);
};
+143
View File
@@ -0,0 +1,143 @@
#include "ReplServer.h"
#include "common/cross_sockets/XSocket.h"
#include "third-party/fmt/core.h"
#include <common/versions.h>
#ifdef _WIN32
#define NOMINMAX
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <WinSock2.h>
#include <WS2tcpip.h>
#endif
// TODO - basically REPL to listen and inject commands into a running REPL
// - we will need a C++ side client as well which will let us communicate with the repl via for
// example, ImgUI
//
// TODO - The server also needs to eventually return the result of the evaluation
ReplServer::~ReplServer() {
// Close all our client sockets!
for (const int& sock : client_sockets) {
close_socket(sock);
}
}
void ReplServer::post_init() {
// Add the listening socket to our set of sockets
fmt::print("[nREPL:{}:{}] awaiting connections\n", tcp_port, listening_socket);
}
void ReplServer::ping_response(int socket) {
std::string ping = fmt::format("Connected to OpenGOAL v{}.{} nREPL!",
versions::GOAL_VERSION_MAJOR, versions::GOAL_VERSION_MINOR);
write_to_socket(socket, ping.c_str(), ping.size());
}
std::optional<std::string> ReplServer::get_msg() {
// Clear the sockets we are listening on
FD_ZERO(&read_sockets);
// Add the server's main listening socket (where we accept clients from)
FD_SET(listening_socket, &read_sockets);
int max_sd = listening_socket;
for (const int& sock : client_sockets) {
if (sock > max_sd) {
max_sd = sock;
}
if (sock > 0) {
FD_SET(sock, &read_sockets);
}
}
// Wait for activity on _something_, with a timeout so we don't get stuck here on exit.
struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 100000;
auto activity = select(max_sd + 1, &read_sockets, NULL, NULL, &timeout);
if (activity < 0) { // TODO - || error!
return std::nullopt;
}
// If something happened on the master socket - it's a new connection
if (FD_ISSET(listening_socket, &read_sockets)) {
socklen_t addr_len = sizeof(addr);
auto new_socket = accept_socket(listening_socket, (sockaddr*)&addr, &addr_len);
if (new_socket < 0) {
// TODO - handle error
} else {
fmt::print("[nREPL:{}]: New socket connection: {}:{}:{}\n", tcp_port,
inet_ntoa(addr.sin_addr), ntohs(addr.sin_port), new_socket);
// Say hello
ping_response(new_socket);
// Track the new socket
if ((int)client_sockets.size() < max_clients) {
client_sockets.insert(new_socket);
} else {
// TODO - Respond with NO
}
}
}
// otherwise (and no matter what) check all the clients to see if they have sent us anything
// else its some IO operation on some other socket
//
// RACE - the first client wins
// TODO - there are ways to do this with iterators but, couldn't figure it out!
std::vector<int> sockets_to_scan(client_sockets.begin(), client_sockets.end());
for (const int& sock : sockets_to_scan) {
if (FD_ISSET(sock, &read_sockets)) {
// Attempt to read a header
// TODO - should this be in a loop?
auto req_bytes = read_from_socket(sock, header_buffer.data(), header_buffer.size());
if (req_bytes == 0) {
// Socket disconnected
// TODO - add a queue of messages in the ReplWrapper so we can print _BEFORE_ the prompt is
// output
fmt::print("[nREPL:{}] Client Disconnected: {}\n", tcp_port, inet_ntoa(addr.sin_addr),
ntohs(addr.sin_port), sock);
// Cleanup the socket and remove it from our set
close_socket(sock);
client_sockets.erase(sock);
} else {
// Otherwise, process the message
auto* header = (ReplServerHeader*)(header_buffer.data());
// get the body of the message
int expected_size = header->length;
int got = 0;
while (got < expected_size) {
if (got + expected_size > (int)buffer.size()) {
fmt::print(stderr,
"[nREPL:{}]: Bad message, aborting the read. Got :{}, Expected: {}, Buffer "
"Size: {}",
tcp_port, got, expected_size, buffer.size());
return std::nullopt;
}
auto x = read_from_socket(sock, buffer.data() + got, expected_size - got);
if (want_exit_callback()) {
return std::nullopt;
}
got += x > 0 ? x : 0;
}
switch (header->type) {
case ReplServerMessageType::PING:
ping_response(sock);
return std::nullopt;
case ReplServerMessageType::EVAL:
std::string msg(buffer.data(), header->length);
return std::make_optional(msg);
}
}
}
}
return std::nullopt;
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include "common/cross_sockets/XSocketServer.h"
#include <set>
#include <optional>
enum ReplServerMessageType { PING = 0, EVAL = 10, SHUTDOWN = 20 };
struct ReplServerHeader {
u32 length;
u32 type;
};
class ReplServer : public XSocketServer {
public:
using XSocketServer::XSocketServer;
virtual ~ReplServer();
void post_init() override;
std::optional<std::string> get_msg();
private:
int max_clients = 50;
std::vector<char> header_buffer = std::vector<char>((int)sizeof(ReplServerHeader));
fd_set read_sockets;
std::set<int> client_sockets = {};
void ping_response(int socket);
};
@@ -0,0 +1,368 @@
#include "subtitles.h"
#include "common/goos/ParseHelpers.h"
#include "common/goos/Reader.h"
#include "common/util/FileUtil.h"
#include "third-party/fmt/core.h"
#include "common/util/json_util.h"
static const std::unordered_map<std::string, GameTextVersion> s_text_ver_enum_map = {
{"jak1-v1", GameTextVersion::JAK1_V1}};
// TODO - why not just return the inputs instead of passing in an empty one?
void open_text_project(const std::string& kind,
const std::string& filename,
std::unordered_map<GameTextVersion, std::vector<std::string>>& inputs) {
goos::Reader reader;
auto& proj = reader.read_from_file({filename}).as_pair()->cdr.as_pair()->car;
if (!proj.is_pair() || !proj.as_pair()->car.is_symbol() ||
proj.as_pair()->car.as_symbol()->name != kind) {
throw std::runtime_error(fmt::format("invalid {} project", kind));
}
goos::for_each_in_list(proj.as_pair()->cdr, [&](const goos::Object& o) {
if (!o.is_pair()) {
throw std::runtime_error(fmt::format("invalid entry in {} project", kind));
}
auto& ver = o.as_pair()->car.as_symbol()->name;
auto& in = o.as_pair()->cdr.as_pair()->car.as_string()->data;
inputs[s_text_ver_enum_map.at(ver)].push_back(in);
});
}
int64_t get_int(const goos::Object& obj) {
if (obj.is_int()) {
return obj.integer_obj.value;
}
throw std::runtime_error(obj.print() + " was supposed to be an integer, but isn't");
}
const goos::Object& car(const goos::Object& x) {
if (!x.is_pair()) {
throw std::runtime_error("invalid pair");
}
return x.as_pair()->car;
}
const goos::Object& cdr(const goos::Object& x) {
if (!x.is_pair()) {
throw std::runtime_error("invalid pair");
}
return x.as_pair()->cdr;
}
std::string get_string(const goos::Object& x) {
if (x.is_string()) {
return x.as_string()->data;
}
throw std::runtime_error(x.print() + " was supposed to be a string, but isn't");
}
/*!
* Parse a game text file.
* Information is added to the game text database.
*
* The file should begin with (language-id x y z...) with the given language IDs.
* Each entry should be (id "line for 1st language" "line for 2nd language" ...)
* This adds the text line to each of the specified languages.
*/
void parse_text(const goos::Object& data, GameTextVersion text_ver, GameTextDB& db) {
auto font = get_font_bank(text_ver);
std::vector<std::shared_ptr<GameTextBank>> banks;
std::string possible_group_name;
for_each_in_list(data.as_pair()->cdr, [&](const goos::Object& obj) {
if (obj.is_pair()) {
auto& head = car(obj);
if (head.is_symbol() && head.as_symbol()->name == "language-id") {
if (banks.size() != 0) {
throw std::runtime_error("Languages have been set multiple times.");
}
if (cdr(obj).is_empty_list()) {
throw std::runtime_error("At least one language must be set.");
}
if (possible_group_name.empty()) {
throw std::runtime_error("Text group must be set before languages.");
}
for_each_in_list(cdr(obj), [&](const goos::Object& obj) {
auto lang = get_int(obj);
if (!db.bank_exists(possible_group_name, lang)) {
// database has no lang in this group yet
banks.push_back(db.add_bank(possible_group_name, std::make_shared<GameTextBank>(lang)));
} else {
banks.push_back(db.bank_by_id(possible_group_name, lang));
}
});
} else if (head.is_symbol() && head.as_symbol()->name == "group-name") {
if (!possible_group_name.empty()) {
throw std::runtime_error("group-name has been set multiple times.");
}
possible_group_name = get_string(car(cdr(obj)));
if (possible_group_name.empty()) {
throw std::runtime_error("invalid group-name.");
}
if (!cdr(cdr(obj)).is_empty_list()) {
throw std::runtime_error("group-name has too many arguments");
}
}
else if (head.is_int()) {
if (banks.size() == 0) {
throw std::runtime_error("At least one language must be set before defining entries.");
}
int i = 0;
int id = head.as_int();
for_each_in_list(cdr(obj), [&](const goos::Object& entry) {
if (entry.is_string()) {
if (i >= int(banks.size())) {
throw std::runtime_error(fmt::format("Too many strings in text id #x{:x}", id));
}
auto line = font->convert_utf8_to_game(entry.as_string()->data);
banks[i++]->set_line(id, line);
} else {
throw std::runtime_error(fmt::format("Non-string value in text id #x{:x}", id));
}
});
if (i != int(banks.size())) {
throw std::runtime_error(
fmt::format("Not enough strings specified in text id #x{:x}", id));
}
} else {
throw std::runtime_error("Invalid game text file entry: " + head.print());
}
} else {
throw std::runtime_error("Invalid game text file");
}
});
if (banks.size() == 0) {
throw std::runtime_error("At least one language must be set.");
}
}
/*!
* Parse a game subtitle file.
* Information is added to the game subtitles database.
*
* The file should begin with (language-id x y z...) for the given language IDs.
* Each scene should be (scene-name <entry 1> <entry 2> ... )
* This adds the subtitle to each of the specified languages.
*/
void parse_subtitle(const goos::Object& data,
GameTextVersion text_ver,
GameSubtitleDB& db,
const std::string& file_path) {
auto font = get_font_bank(text_ver);
std::map<int, std::shared_ptr<GameSubtitleBank>> banks;
for_each_in_list(data.as_pair()->cdr, [&](const goos::Object& obj) {
if (obj.is_pair()) {
auto& head = car(obj);
if (head.is_symbol() && head.as_symbol()->name == "language-id") {
if (banks.size() != 0) {
throw std::runtime_error("Languages have been set multiple times.");
}
if (cdr(obj).is_empty_list()) {
throw std::runtime_error("At least one language must be set.");
}
for_each_in_list(cdr(obj), [&](const goos::Object& obj) {
auto lang = get_int(obj);
if (!db.bank_exists(lang)) {
// database has no lang yet
banks[lang] = db.add_bank(std::make_shared<GameSubtitleBank>(lang));
banks[lang]->file_path = file_path;
} else {
banks[lang] = db.bank_by_id(lang);
banks[lang]->file_path = file_path;
}
});
}
else if (head.is_string() || head.is_int()) {
if (banks.size() == 0) {
throw std::runtime_error("At least one language must be set before defining scenes.");
}
auto kind = SubtitleSceneKind::Movie;
int id = 0;
auto entries = cdr(obj);
if (head.is_int()) {
kind = SubtitleSceneKind::Hint;
} else if (car(entries).is_symbol()) {
const auto& parm = car(entries).as_symbol()->name;
if (parm == ":hint") {
entries = cdr(entries);
id = car(entries).as_int();
kind = SubtitleSceneKind::HintNamed;
} else {
throw std::runtime_error("Unknown parameter for subtitle scene");
}
entries = cdr(entries);
}
GameSubtitleSceneInfo scene(kind);
if (kind == SubtitleSceneKind::Movie || kind == SubtitleSceneKind::HintNamed) {
scene.set_name(head.as_string()->data);
} else if (kind == SubtitleSceneKind::Hint) {
id = head.as_int();
}
scene.set_id(id);
scene.m_sorting_group = db.m_subtitle_groups->find_group(scene.name());
scene.m_sorting_group_idx = db.m_subtitle_groups->find_group_index(scene.m_sorting_group);
for_each_in_list(entries, [&](const goos::Object& entry) {
if (entry.is_pair()) {
// expected formats:
// (time <args>)
// all arguments have default values. the arguments are:
// "speaker" "line" - two strings. one for the speaker's name and one for the actual
// line. speaker can be empty. default is just empty string.
// :offscreen - speaker is offscreen. default is not offscreen.
if (!car(entry).is_int()) {
throw std::runtime_error("Each entry must start with a timestamp (number)");
}
auto time = car(entry).as_int();
goos::StringObject *speaker = nullptr, *line = nullptr;
bool offscreen = false;
if (scene.kind() == SubtitleSceneKind::Hint ||
scene.kind() == SubtitleSceneKind::HintNamed) {
offscreen = true;
}
for_each_in_list(cdr(entry), [&](const goos::Object& arg) {
if (arg.is_string()) {
if (!speaker) {
speaker = arg.as_string();
} else if (!line) {
line = arg.as_string();
} else {
throw std::runtime_error("Invalid string in subtitle entry");
}
} else if (speaker && !line) {
throw std::runtime_error(
"Invalid object in subtitle entry, expecting actual line string after speaker");
} else if (arg.is_symbol()) {
if (scene.kind() == SubtitleSceneKind::Movie &&
arg.as_symbol()->name == ":offscreen") {
offscreen = true;
} else {
throw std::runtime_error(
fmt::format("Unknown parameter {} in subtitle", arg.as_symbol()->name));
}
}
});
auto line_utf8 = line ? line->data : "";
auto line_str = font->convert_utf8_to_game(line_utf8);
auto speaker_utf8 = speaker ? speaker->data : "";
auto speaker_str = font->convert_utf8_to_game(speaker_utf8);
scene.add_line(time, line_str, line_utf8, speaker_str, speaker_utf8, offscreen);
} else {
throw std::runtime_error("Each entry must be a list");
}
});
for (auto& [lang, bank] : banks) {
if (!bank->scene_exists(scene.name())) {
bank->add_scene(scene);
} else {
auto& old_scene = bank->scene_by_name(scene.name());
old_scene.from_other_scene(scene);
}
}
} else {
throw std::runtime_error("Invalid game subtitles file entry: " + head.print());
}
} else {
throw std::runtime_error("Invalid game subtitles file");
}
});
if (banks.size() == 0) {
throw std::runtime_error("At least one language must be set.");
}
}
void GameSubtitleGroups::hydrate_from_asset_file() {
std::string file_path = (file_util::get_jak_project_dir() / "game" / "assets" / "jak1" /
"subtitle" / "subtitle-groups.json")
.string();
auto config_str = file_util::read_text_file(file_path);
auto group_data = parse_commented_json(config_str, file_path);
for (const auto& [key, val] : group_data.items()) {
try {
if (key == group_order_key) {
m_group_order = val.get<std::vector<std::string>>();
} else {
m_groups[key] = val.get<std::vector<std::string>>();
}
} catch (std::exception& ex) {
fmt::print("Bad subtitle group entry - {} - {}", key, ex.what());
}
}
}
std::string GameSubtitleGroups::find_group(const std::string& scene_name) {
for (auto const& [group, scenes] : m_groups) {
for (auto const& name : scenes) {
if (name == scene_name) {
return group;
}
}
}
// Add to the uncategorized group if it wasn't found
m_groups[uncategorized_group].push_back(scene_name);
return uncategorized_group;
}
int GameSubtitleGroups::find_group_index(const std::string& group_name) {
auto it = find(m_group_order.begin(), m_group_order.end(), group_name);
if (it != m_group_order.end()) {
return it - m_group_order.begin();
} else {
return m_group_order.size() - 1;
}
}
void GameSubtitleGroups::remove_scene(const std::string& group_name,
const std::string& scene_name) {
// TODO - validate group_name
m_groups[group_name].erase(
std::remove(m_groups[group_name].begin(), m_groups[group_name].end(), scene_name),
m_groups[group_name].end());
}
void GameSubtitleGroups::add_scene(const std::string& group_name, const std::string& scene_name) {
// TODO - validate group_name
// TODO - don't add duplicates
m_groups[group_name].push_back(scene_name);
}
GameSubtitleDB load_subtitle_project() {
// Load the subtitle files
GameSubtitleDB db;
db.m_subtitle_groups = std::make_unique<GameSubtitleGroups>();
db.m_subtitle_groups->hydrate_from_asset_file();
goos::Reader reader;
std::unordered_map<GameTextVersion, std::vector<std::string>> inputs;
std::string subtitle_project =
(file_util::get_jak_project_dir() / "game" / "assets" / "game_subtitle.gp").string();
open_text_project("subtitle", subtitle_project, inputs);
for (auto& [ver, in] : inputs) {
for (auto& filename : in) {
auto code = reader.read_from_file({filename});
parse_subtitle(code, ver, db, filename);
}
}
return db;
}
// TODO - write a deserializer, the compiler still can do the compiling!
+206
View File
@@ -0,0 +1,206 @@
#pragma once
#include "common/util/FontUtils.h"
#include "common/util/Assert.h"
#include "common/goos/Object.h"
#include <string>
#include <map>
#include <unordered_set>
#include <memory>
#include <algorithm>
/*!
* The text bank contains all lines (accessed with an ID) for a language.
*/
class GameTextBank {
public:
GameTextBank(int lang_id) : m_lang_id(lang_id) {}
int lang() const { return m_lang_id; }
const std::map<int, std::string>& lines() const { return m_lines; }
bool line_exists(int id) const { return m_lines.find(id) != m_lines.end(); }
std::string line(int id) { return m_lines.at(id); }
void set_line(int id, std::string line) { m_lines[id] = line; }
private:
int m_lang_id;
std::map<int, std::string> m_lines;
};
/*!
* The text database contains a text bank for each language for each text group.
* Each text bank contains a list of text lines. Very simple.
*/
class GameTextDB {
public:
const std::unordered_map<std::string, std::map<int, std::shared_ptr<GameTextBank>>>& groups()
const {
return m_banks;
}
const std::map<int, std::shared_ptr<GameTextBank>>& banks(std::string group) const {
return m_banks.at(group);
}
bool bank_exists(std::string group, int id) const {
if (m_banks.find(group) == m_banks.end())
return false;
return m_banks.at(group).find(id) != m_banks.at(group).end();
}
std::shared_ptr<GameTextBank> add_bank(std::string group, std::shared_ptr<GameTextBank> bank) {
ASSERT(!bank_exists(group, bank->lang()));
m_banks[group][bank->lang()] = bank;
return bank;
}
std::shared_ptr<GameTextBank> bank_by_id(std::string group, int id) {
if (!bank_exists(group, id)) {
return nullptr;
}
return m_banks.at(group).at(id);
}
private:
std::unordered_map<std::string, std::map<int, std::shared_ptr<GameTextBank>>> m_banks;
};
/*!
* The subtitle scene info (accessed through the scene name) contains all lines and their timestamps
* and other settings.
*/
enum class SubtitleSceneKind { Invalid = -1, Movie = 0, Hint = 1, HintNamed = 2 };
class GameSubtitleSceneInfo {
public:
struct SubtitleLine {
SubtitleLine(int frame,
std::string line,
std::string line_utf8,
std::string speaker,
std::string speaker_utf8,
bool offscreen)
: frame(frame),
line(line),
line_utf8(line_utf8),
speaker(speaker),
speaker_utf8(speaker_utf8),
offscreen(offscreen) {}
int frame;
std::string line;
std::string line_utf8;
std::string speaker;
std::string speaker_utf8;
bool offscreen;
bool operator<(const SubtitleLine& line) const { return (frame < line.frame); }
};
GameSubtitleSceneInfo() {}
GameSubtitleSceneInfo(SubtitleSceneKind kind) : m_kind(kind) {}
const std::string& name() const { return m_name; }
const std::vector<SubtitleLine>& lines() const { return m_lines; }
int id() const { return m_id; }
SubtitleSceneKind kind() const { return m_kind; }
void clear_lines() { m_lines.clear(); }
void set_name(const std::string& new_name) { m_name = new_name; }
void set_id(int new_id) { m_id = new_id; }
void from_other_scene(GameSubtitleSceneInfo& scene) {
m_name = scene.name();
m_lines = scene.lines();
m_kind = scene.kind();
m_id = scene.id();
}
void add_line(int frame,
std::string line,
std::string line_utf8,
std::string speaker,
std::string speaker_utf8,
bool offscreen) {
m_lines.emplace_back(SubtitleLine(frame, line, line_utf8, speaker, speaker_utf8, offscreen));
std::sort(m_lines.begin(), m_lines.end());
}
std::string m_name;
int m_id;
std::vector<SubtitleLine> m_lines;
SubtitleSceneKind m_kind;
std::string m_sorting_group;
int m_sorting_group_idx;
};
/*!
* The subtitle bank contains subtitles for all scenes in a language.
*/
class GameSubtitleBank {
public:
GameSubtitleBank(int lang_id) : m_lang_id(lang_id) {}
int lang() const { return m_lang_id; }
const std::map<std::string, GameSubtitleSceneInfo>& scenes() const { return m_scenes; }
bool scene_exists(const std::string& name) const { return m_scenes.find(name) != m_scenes.end(); }
GameSubtitleSceneInfo& scene_by_name(const std::string& name) { return m_scenes.at(name); }
void add_scene(GameSubtitleSceneInfo& scene) {
ASSERT(!scene_exists(scene.name()));
m_scenes[scene.name()] = scene;
}
int m_lang_id;
std::string file_path;
std::map<std::string, GameSubtitleSceneInfo> m_scenes;
};
class GameSubtitleGroups {
public:
std::vector<std::string> m_group_order;
std::map<std::string, std::vector<std::string>> m_groups;
void hydrate_from_asset_file();
std::string find_group(const std::string& scene_name);
int find_group_index(const std::string& group_name);
void remove_scene(const std::string& group_name, const std::string& scene_name);
void add_scene(const std::string& group_name, const std::string& scene_name);
std::string group_order_key = "_groups";
std::string uncategorized_group = "uncategorized";
};
/*!
* The subtitles database contains a subtitles bank for each language.
* Each subtitles bank contains a series of subtitle scene infos.
*/
class GameSubtitleDB {
public:
const std::map<int, std::shared_ptr<GameSubtitleBank>>& banks() const { return m_banks; }
bool bank_exists(int id) const { return m_banks.find(id) != m_banks.end(); }
std::shared_ptr<GameSubtitleBank> add_bank(std::shared_ptr<GameSubtitleBank> bank) {
ASSERT(!bank_exists(bank->lang()));
m_banks[bank->lang()] = bank;
return bank;
}
std::shared_ptr<GameSubtitleBank> bank_by_id(int id) {
if (!bank_exists(id)) {
return nullptr;
}
return m_banks.at(id);
}
std::map<int, std::shared_ptr<GameSubtitleBank>> m_banks;
std::unique_ptr<GameSubtitleGroups> m_subtitle_groups;
};
// TODO add docstrings
void parse_text(const goos::Object& data, GameTextVersion text_ver, GameTextDB& db);
void parse_subtitle(const goos::Object& data,
GameTextVersion text_ver,
GameSubtitleDB& db,
const std::string& file_path);
GameSubtitleDB load_subtitle_project();