Created
August 31, 2020 18:36
-
-
Save nikki93/5893543a5e3d76a7858d54b74a4c1a16 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #include "precomp.h" | |
| #include "edit.h" | |
| #include "ui.h" | |
| // Frame | |
| auto Edit::frame() -> void { | |
| input(); | |
| // Box update rules | |
| ker.run<UpdateBoxes>(); | |
| } | |
| auto Edit::input() -> void { | |
| // Input rules | |
| ker.run<Input>(); | |
| auto &ev = ker.ctx<Events>(); | |
| auto &touches = ev.getTouches(); | |
| if (mode == "select") { | |
| // Touch-to-select | |
| if (touches.size() == 1 && touches[0].pressed) { | |
| // Collect hits in ascending area order | |
| std::vector<std::pair<double, Entity>> hits; | |
| ker.view<Box>().each([&](const Entity ent, const Box &box) { | |
| if (abs(touches[0].x - box.x) < 0.5 * box.width | |
| && abs(touches[0].y - box.y) < 0.5 * box.height) { | |
| hits.emplace_back(box.width * box.height, ent); | |
| } | |
| }); | |
| std::sort(hits.begin(), hits.end()); | |
| // Pick after current selection or first if none | |
| auto pick = Kernel::null; | |
| auto pickNext = true; | |
| for (const auto &[order, ent] : hits) { | |
| if (ker.has<Select>(ent)) { | |
| pickNext = true; | |
| } else if (pickNext) { | |
| pick = ent; | |
| pickNext = false; | |
| } | |
| } | |
| ker.clear<Select>(); | |
| if (pick != Kernel::null) { | |
| ker.add<Select>(pick); | |
| } | |
| } | |
| } | |
| if (mode == "pan") { | |
| // Pan view by dragging | |
| if (touches.size() == 1) { | |
| auto [zx, zy] = gfx.viewToWorld(0, 0); | |
| auto [dx, dy] = gfx.viewToWorld(touches[0].screenDX, touches[0].screenDY); | |
| viewX -= dx - zx; | |
| viewY -= dy - zy; | |
| } | |
| } | |
| } | |
| // Draw | |
| auto Edit::applyView() -> void { | |
| gfx.setView(viewX, viewY, viewWidth, viewHeight); | |
| } | |
| auto Edit::draw() -> void { | |
| if (mode == "select") { | |
| // Red boxes for unselected | |
| gfx.scope([&]() { | |
| gfx.setColor(0xff, 0, 0); | |
| ker.view<Box>(Kernel::exclude<Select>).each([&](const Box &box) { | |
| gfx.drawRectangle(box.x, box.y, box.width, box.height); | |
| }); | |
| }); | |
| // Doubled green boxes for selected | |
| gfx.scope([&]() { | |
| double vx = 0, vy = 0, vw = 0, vh = 0; | |
| std::tie(vx, vy, vw, vh) = gfx.getView(); | |
| gfx.setColor(0, 0x80, 0x40); | |
| ker.view<Select, Box>().each([&](const Box &box) { | |
| auto x = box.x, y = box.y, w = box.width, h = box.height; | |
| if (x - 0.5 * w <= vx - 0.5 * vw && x + 0.5 * w >= vx + 0.5 * vw | |
| && y - 0.5 * h <= vy - 0.5 * vh && y + 0.5 * h >= vy + 0.5 * vh) { | |
| // Covers entire view, just draw at view boundary | |
| x = vx; | |
| y = vy; | |
| w = vw - 4; | |
| h = vh - 4; | |
| } | |
| gfx.drawRectangle(x, y, w, h); | |
| gfx.drawRectangle(x, y, w + 4, h + 4); | |
| }); | |
| }); | |
| } | |
| // Draw rules | |
| ker.run<Draw>(); | |
| } | |
| // UI | |
| inline static auto titleify(const char *s) -> std::string { | |
| std::string r = s; | |
| for (size_t i = 0; i < r.length(); ++i) { | |
| if (std::isupper(r[i])) { | |
| if (i > 0) { | |
| r.insert(i++, " "); | |
| } | |
| r[i] = std::tolower(r[i]); | |
| } | |
| } | |
| return r; | |
| } | |
| auto Edit::uiToolbar() -> void { | |
| ui.button()(enabled ? "play" : "stop")("click", [&](emscripten::val) { | |
| if (enabled) { | |
| play(); | |
| } else { | |
| stop(); | |
| } | |
| }); | |
| ui.div()("spacer"); | |
| if (enabled) { | |
| ui.button()("undo")("disabled", undos.size() <= 1)("click", [&](emscripten::val) { | |
| undo(); | |
| }); | |
| ui.button()("redo")("disabled", redos.size() == 0)("click", [&](emscripten::val) { | |
| redo(); | |
| }); | |
| ui.div()("small-gap"); | |
| ui.button()("pan")("selected", mode == "pan")("click", [&](emscripten::val) { | |
| mode = mode != "pan" ? "pan" : "select"; | |
| }); | |
| ui.button()("zoom-in")("click", [&](emscripten::val) { | |
| viewWidth *= 0.5; | |
| viewHeight *= 0.5; | |
| }); | |
| ui.button()("zoom-out")("click", [&](emscripten::val) { | |
| viewWidth *= 2; | |
| viewHeight *= 2; | |
| }); | |
| } | |
| } | |
| auto Edit::uiStatus() -> void { | |
| if (enabled) { | |
| ui.div()([&]() { | |
| ui.text("{}x", viewWidth / 800); | |
| }); | |
| ui.div()("small-gap"); | |
| ui.div()([&]() { | |
| ui.text(mode); | |
| }); | |
| } | |
| } | |
| auto Edit::uiInspect() -> void { | |
| // Inspector for selected | |
| ker.view<Select>().each([&](const Entity ent) { | |
| ui.div()("inspector")([&]() { | |
| // Callbacks to run after. Prevents the UI from displaying inconsistent states. | |
| std::vector<std::function<void(void)>> after; | |
| // Section for each type the entity has | |
| ker.types(ent, [&](const Kernel::MetaType &type, const char *typeName) { | |
| // Header with lowercase type name and remove button | |
| auto typeTitle = titleify(typeName); | |
| ui.key(typeTitle).elem("details")(typeTitle)("open", true)([&]() { | |
| ui.elem("summary")([&]() { | |
| ui.text(typeTitle); | |
| ui.button()("remove")("click", [&](emscripten::val) { | |
| after.emplace_back([=]() { | |
| ker.remove(type, ent); | |
| action(fmt::format("remove {}", typeTitle)); | |
| }); | |
| }); | |
| }); | |
| // Details for type instance | |
| if (auto inst = ker.get(type, ent)) { | |
| // Custom inspect | |
| if (auto inspectFn = type.func("uiInspect"_hs)) { | |
| inspectFn.invoke({}, inst, std::ref(ker), ent); | |
| } | |
| // Regular fields | |
| Kernel::fields(type, [&](const Kernel::MetaField &field, const char *fieldName) { | |
| auto value = field.get(inst); | |
| ui.div()("info")([&]() { | |
| if (auto p = value.try_cast<double>()) { | |
| ui.text("{}: {:.2f}", fieldName, *p); | |
| } | |
| }); | |
| }); | |
| } | |
| }); | |
| }); | |
| // Add button for each addable type the entity doesn't have | |
| ui.div()("add-bar")([&]() { | |
| for (auto &type : ker.types()) { | |
| if (auto typeName = ker.typeName(type)) { | |
| if (!ker.has(type, ent) && ker.addable(type, ent)) { | |
| // Button with lowercase type name | |
| auto typeTitle = titleify(typeName); | |
| ui.button()("add")("label", typeTitle)("click", [&](emscripten::val) { | |
| ker.add(type, ent); | |
| action(fmt::format("add {}", typeTitle)); | |
| }); | |
| } | |
| } | |
| } | |
| }); | |
| // Run the after callbacks | |
| for (auto &func : after) { | |
| func(); | |
| } | |
| }); | |
| }); | |
| } | |
| // Undo / redo | |
| auto Edit::action(std::string desc) -> void { | |
| Archive ar; | |
| ar.save(ker, [&](const Entity ent, Archive::Writer &w) { | |
| if (ker.has<Select>(ent)) { | |
| w.boolean("select", true); | |
| } | |
| }); | |
| undos.push_back(Action { std::move(desc), std::move(ar) }); | |
| while (undos.size() > 50) { | |
| undos.pop_front(); | |
| } | |
| redos.clear(); | |
| } | |
| auto Edit::clearActions() -> void { | |
| undos.clear(); | |
| redos.clear(); | |
| } | |
| auto Edit::swapAction(std::deque<Action> &from, std::deque<Action> &to) -> void { | |
| to.push_back(std::move(from.back())); | |
| from.pop_back(); | |
| restore(); | |
| } | |
| auto Edit::undo() -> void { | |
| if (undos.size() > 1) { | |
| swapAction(undos, redos); | |
| fmt::print("undid: {}\n", redos.back().desc); | |
| } | |
| } | |
| auto Edit::redo() -> void { | |
| if (redos.size() > 0) { | |
| swapAction(redos, undos); | |
| fmt::print("redid: {}\n", undos.back().desc); | |
| } | |
| } | |
| auto Edit::restore() -> void { | |
| if (undos.size() > 0) { | |
| ker.clear(); | |
| undos.back().ar.load(ker, [&](const Entity ent, Archive::Reader &r) { | |
| if (r.has("select")) { | |
| ker.add<Select>(ent); | |
| } | |
| }); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* Dependencies */ | |
| @import url('https://necolas.github.io/normalize.css/8.0.1/normalize.css'); | |
| @import url('https://rsms.me/inter/inter.css'); | |
| @import url('https://kit-free.fontawesome.com/releases/latest/css/free-v4-font-face.min.css'); | |
| @import url('https://kit-free.fontawesome.com/releases/latest/css/free-v4-shims.min.css'); | |
| @import url('https://kit-free.fontawesome.com/releases/latest/css/free.min.css'); | |
| /* Font */ | |
| html { | |
| font-family: 'Inter', sans-serif; | |
| } | |
| @supports (font-variation-settings: normal) { | |
| html { | |
| font-family: 'Inter var', sans-serif; | |
| } | |
| } | |
| /* Basics */ | |
| html { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| body { | |
| background-color: #121212; | |
| color: rgba(255, 255, 255, 0.8); | |
| font-size: 14px; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* --- Layout --------------------------------------------------------------------- */ | |
| /* Root */ | |
| div.root-container { | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| flex-direction: row; | |
| } | |
| /* Main */ | |
| div.main-container { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| /* Scene */ | |
| div.scene-container { | |
| background-color: black; | |
| flex: 1; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| overflow: hidden; | |
| } | |
| div.scene-aspect-ratio { | |
| width: 100%; | |
| padding-top: 56.25%; | |
| position: relative; | |
| } | |
| canvas.scene { | |
| background-color: black; | |
| outline: none; | |
| -webkit-tap-highlight-color: transparent; | |
| position: absolute; | |
| left: 0; | |
| top: 0; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* Top */ | |
| div.top-panel { | |
| height: 100%; | |
| display: flex; | |
| flex-direction: row; | |
| } | |
| /* Bottom */ | |
| div.bottom-panel { | |
| height: 100%; | |
| display: flex; | |
| flex-direction: row; | |
| } | |
| /* Side */ | |
| div.side-container { | |
| flex: 0.5; | |
| max-width: 400px; | |
| position: relative; | |
| } | |
| div.side-panel { | |
| position: absolute; /* Force layout boundary */ | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| /* Status */ | |
| div.status { | |
| flex: 1; | |
| padding: 4px 14px 4px 14px; | |
| display: flex; | |
| flex-direction: row; | |
| align-items: center; | |
| } | |
| div.status > *:not(:last-child) { | |
| margin-right: 8px; | |
| } | |
| div.status > div.spacer { | |
| flex: 1; | |
| } | |
| div.toolbar > div.small-gap { | |
| width: 12px; | |
| } | |
| /* Toolbar */ | |
| div.toolbar { | |
| flex: 1; | |
| padding: 6px 16px 6px 16px; | |
| display: flex; | |
| flex-direction: row; | |
| align-items: center; | |
| } | |
| div.toolbar > *:not(:last-child) { | |
| margin-right: 8px; | |
| } | |
| div.toolbar > div.spacer { | |
| flex: 1; | |
| } | |
| div.toolbar > div.small-gap { | |
| width: 12px; | |
| } | |
| /* Inspector */ | |
| div.inspector { | |
| flex: 1; | |
| padding: 6px 16px 6px 16px; | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: scroll; | |
| -ms-overflow-style: none; | |
| scrollbar-width: none; | |
| } | |
| div.inspector::-webkit-scrollbar { | |
| display: none; | |
| } | |
| /* Inspector sections */ | |
| div.inspector > details { | |
| padding: 8px; | |
| } | |
| div.inspector > details > summary { | |
| font-size: 16px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| user-select: none; | |
| -moz-user-select: none; | |
| -khtml-user-select: none; | |
| -webkit-user-select: none; | |
| padding: 6px; | |
| margin-left: -4px; | |
| margin-right: -4px; | |
| border-radius: 8px; | |
| transition: 0.1s; | |
| } | |
| div.inspector > details > summary:focus { | |
| outline: 0; | |
| } | |
| @media (hover: hover) { | |
| div.inspector > details > summary:hover { | |
| background-color: rgba(255, 255, 255, 0.1); | |
| } | |
| } | |
| div.inspector > details[open] > summary { | |
| margin-bottom: 4px; | |
| } | |
| div.inspector > details[open] { | |
| margin-bottom: 4px; | |
| } | |
| /* Inspector header extra buttons */ | |
| div.inspector > details > summary > button { | |
| margin-top: -6px; | |
| margin-right: -6px; | |
| float: right; | |
| font-weight: normal; | |
| } | |
| /* Inspector info text */ | |
| div.inspector > details > div.info { | |
| display: flex; | |
| flex-direction: row; | |
| padding: 6px; | |
| align-items: center; | |
| } | |
| /* Inspector add bar */ | |
| div.inspector > div.add-bar { | |
| padding: 24px; | |
| display: flex; | |
| flex-direction: row; | |
| flex-wrap: wrap; | |
| } | |
| div.inspector > div.add-bar > button { | |
| margin: 4px; | |
| } | |
| /* Sprite inspector */ | |
| div.inspector > details.sprite > img.preview { | |
| margin: 6px; | |
| max-height: 120px; | |
| max-width: 25%; | |
| background-position: 0px 0px, 10px 10px; | |
| background-size: 20px 20px; | |
| background-image: linear-gradient( | |
| 45deg, | |
| #eee 25%, | |
| transparent 25%, | |
| transparent 75%, | |
| #eee 75%, | |
| #eee 100% | |
| ), | |
| linear-gradient(45deg, #eee 25%, white 25%, white 75%, #eee 75%, #eee 100%); | |
| } | |
| /* Feet inspector */ | |
| div.inspector > details.feet > div.info > button.shape { | |
| margin-left: 6px; | |
| } | |
| div.inspector > details.feet > div.info > button.shape::before { | |
| content: '\f040'; | |
| } | |
| /* Scene switcher */ | |
| div.scene-switcher { | |
| display: flex; | |
| flex-direction: row; | |
| align-items: center; | |
| margin: -2px; | |
| } | |
| div.scene-switcher > button { | |
| margin: 0; | |
| } | |
| div.scene-switcher > button.prev::before { | |
| content: '\f060'; | |
| } | |
| div.scene-switcher > button.prev { | |
| content: '\f060'; | |
| border-top-right-radius: 0; | |
| border-bottom-right-radius: 0; | |
| } | |
| div.scene-switcher > button.next::before { | |
| content: '\f061'; | |
| } | |
| div.scene-switcher > button.next { | |
| content: '\f061'; | |
| border-top-left-radius: 0; | |
| border-bottom-left-radius: 0; | |
| } | |
| div.scene-switcher > div.name { | |
| padding-left: 4px; | |
| padding-right: 4px; | |
| } | |
| /* --- Components ----------------------------------------------------------------- */ | |
| /* Button base */ | |
| button { | |
| border: none; | |
| cursor: pointer; | |
| font: inherit; | |
| color: inherit; | |
| background-color: transparent; | |
| display: flex; | |
| flex-direction: row; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 6px; | |
| margin: -2px; | |
| border-radius: 8px; | |
| transition: 0.1s; | |
| } | |
| button:focus { | |
| outline: 0; | |
| } | |
| button::before { | |
| font-size: 16px; | |
| font-family: FontAwesome; | |
| } | |
| button::after { | |
| padding-left: 5px; | |
| } | |
| @media (hover: hover) { | |
| button:hover:not([disabled]) { | |
| background-color: rgba(255, 255, 255, 0.1); | |
| } | |
| } | |
| button:active { | |
| transform: translate(0px, 1px); | |
| } | |
| button[selected] { | |
| color: coral; | |
| } | |
| button[disabled] { | |
| color: #303030; | |
| cursor: default; | |
| } | |
| button[label]::after { | |
| content: attr(label); | |
| } | |
| /* Reload button */ | |
| button.reload::before { | |
| content: '\f021'; | |
| } | |
| /* Add button */ | |
| button.add::before { | |
| content: '\f067'; | |
| } | |
| /* Remove button */ | |
| button.remove::before { | |
| content: '\f00d'; | |
| } | |
| /* Save button */ | |
| button.save::before { | |
| content: '\f0c7'; | |
| } | |
| /* Profiler button */ | |
| button.profiler::before { | |
| content: '\f017'; | |
| } | |
| /* Zoom buttons */ | |
| button.zoom-in::before { | |
| content: '\f067'; | |
| } | |
| button.zoom-out::before { | |
| content: '\f068'; | |
| } | |
| /* Pan button */ | |
| button.pan::before { | |
| content: '\f256'; | |
| } | |
| /* Undo / redo buttons */ | |
| button.undo::before { | |
| content: '\f0e2'; | |
| } | |
| button.redo::before { | |
| content: '\f01e'; | |
| } | |
| /* Play / stop button */ | |
| button.play::before { | |
| content: '\f04b'; | |
| } | |
| button.stop::before { | |
| content: '\f04d'; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #include "precomp.h" | |
| #include "archive.h" | |
| #include "edit.h" | |
| #include "events.h" | |
| #include "graphics.h" | |
| #include "kernel.h" | |
| #include "physics.h" | |
| #include "platform.h" | |
| #include "timing.h" | |
| #include "ui.h" | |
| // | |
| // Types | |
| // | |
| // Basics | |
| struct Position { | |
| ngType(Position); | |
| double x = 0; | |
| ngField(x); | |
| double y = 0; | |
| ngField(y); | |
| }; | |
| struct Sprite { | |
| ngType(Sprite); | |
| Graphics::Image image; | |
| double scale = 0.25; | |
| ngField(scale); | |
| double depth = 0; | |
| ngField(depth); | |
| static auto add(Kernel &ker, const Entity ent, Archive::Reader &bp = Archive::empty) -> void { | |
| auto &gfx = ker.ctx<Graphics>(); | |
| ker.add<Sprite>( | |
| ent, gfx.createImage(Platform::getAssetPath(bp.str("imageName", "player.png")))); | |
| } | |
| ngMethod(add); | |
| auto save(Archive::Writer &bp) -> void { | |
| bp.str("imageName", std::filesystem::path(image.getPath()).filename().u8string()); | |
| } | |
| ngMethod(save); | |
| }; | |
| // Physics | |
| struct Feet { | |
| ngType(Feet); | |
| Physics::Body body; | |
| Physics::Shape shape; | |
| double offsetX = 0, offsetY = 0; | |
| static auto add(Kernel &ker, const Entity ent, Archive::Reader &bp = Archive::empty) -> void { | |
| auto &phy = ker.ctx<Physics>(); | |
| // Body | |
| auto body = !std::strcmp(bp.str("type", "static"), "dynamic") | |
| ? phy.createDynamic(bp.num("mass", 1), bp.num("moment", INFINITY)) | |
| : phy.createStatic(); | |
| body.setEntity(ent); | |
| const auto &pos = ker.get<Position>(ent); | |
| auto offsetX = bp.num("offsetX", 0), offsetY = bp.num("offsetY", 0); | |
| body.setPosition({ pos.x + offsetX, pos.y + offsetY }); | |
| // Shape | |
| std::vector<Vec2> verts; | |
| bp.arr("verts", [&]() { | |
| auto size = bp.size(); | |
| for (auto i = 0; i + 1 < size; i += 2) { | |
| verts.push_back({ bp.num(i), bp.num(i + 1) }); | |
| } | |
| }); | |
| auto shape = verts.size() > 0 ? phy.createPoly(body, verts) : phy.createBox(body, 40, 40); | |
| shape.setRadius(bp.num("radius", 0)); | |
| ker.add<Feet>(ent, std::move(body), std::move(shape), offsetX, offsetY); | |
| } | |
| ngMethod(add); | |
| auto save(Archive::Writer &bp) -> void { | |
| // Body | |
| if (offsetX != 0) { | |
| bp.num("offsetX", offsetX); | |
| } | |
| if (offsetY != 0) { | |
| bp.num("offsetY", offsetY); | |
| } | |
| if (body.getType() == Physics::Body::Type::Dynamic) { | |
| bp.str("type", "dynamic"); | |
| bp.num("mass", body.getMass()); | |
| bp.num("moment", body.getMoment()); | |
| } | |
| // Shape | |
| bp.arr("verts", [&]() { | |
| for (auto nVerts = shape.getNumVertices(), i = 0; i < nVerts; ++i) { | |
| auto [x, y] = shape.getVertex(i); | |
| bp.num(x); | |
| bp.num(y); | |
| } | |
| }); | |
| if (auto radius = shape.getRadius(); radius != 0) { | |
| bp.num("radius", radius); | |
| } | |
| } | |
| ngMethod(save); | |
| }; | |
| struct Walk { | |
| Physics::Body target; | |
| Physics::Constraint constraint; | |
| double touchTime = 0; | |
| static auto add(Kernel &ker, const Entity ent) -> void { | |
| auto &phy = ker.ctx<Physics>(); | |
| if (ker.has<Feet>(ent)) { | |
| auto &feet = ker.get<Feet>(ent); | |
| auto target = phy.createStatic().setPosition(feet.body.getPosition()); | |
| auto constraint = phy.createPivot(target, feet.body, { 0, 0 }, { 0, 0 }) | |
| .setMaxForce(2000) | |
| .setMaxBias(180); | |
| ker.add<Walk>(ent, std::move(target), std::move(constraint), ker.tim.t()); | |
| } | |
| } | |
| }; | |
| struct Friction { | |
| ngType(Friction); | |
| Physics::Constraint constraint; | |
| static auto add(Kernel &ker, const Entity ent) -> void { | |
| auto &phy = ker.ctx<Physics>(); | |
| if (ker.has<Feet>(ent)) { | |
| auto &feet = ker.get<Feet>(ent); | |
| auto constraint = phy.createPivot(phy.getBackground(), feet.body, { 0, 0 }, { 0, 0 }) | |
| .setMaxForce(800) | |
| .setMaxBias(0); | |
| ker.add<Friction>(ent, std::move(constraint)); | |
| } | |
| } | |
| }; | |
| struct WorldBounds { | |
| ngType(WorldBounds); | |
| double minX, maxX, minY, maxY; | |
| ngFields(minX, maxX, minY, maxY); | |
| std::array<Physics::Body, 4> bodies; | |
| std::array<Physics::Shape, 4> shapes; | |
| static auto add(Kernel &ker, const Entity ent, Archive::Reader &bp = Archive::empty) -> void { | |
| double minX = bp.num("minX", 0), maxX = bp.num("maxX", 0); | |
| double minY = bp.num("minY", 0), maxY = bp.num("maxY", 0); | |
| if (ker.has<Position>(ent) && ker.has<Sprite>(ent)) { // Auto-read from sprite | |
| auto &pos = ker.get<Position>(ent); | |
| auto &spr = ker.get<Sprite>(ent); | |
| auto [imgW, imgH] = spr.image.getSize(); | |
| auto w = spr.scale * imgW, h = spr.scale * imgH; | |
| minX = pos.x - 0.5 * w; | |
| maxX = pos.x + 0.5 * w; | |
| minY = pos.y - 0.5 * h; | |
| maxY = pos.y + 0.5 * h; | |
| } | |
| if (maxX > minX && maxY > minY) { | |
| auto &phy = ker.ctx<Physics>(); | |
| constexpr auto border = 100; | |
| std::array bodies { | |
| phy.createStatic().setPosition({ minX - 0.5 * border, 0.5 * (minY + maxY) }), // Left | |
| phy.createStatic().setPosition({ maxX + 0.5 * border, 0.5 * (minY + maxY) }), // Right | |
| phy.createStatic().setPosition({ 0.5 * (minX + maxX), minY - 0.5 * border }), // Top | |
| phy.createStatic().setPosition({ 0.5 * (minX + maxX), maxY + 0.5 * border }), // Bottom | |
| }; | |
| std::array shapes { | |
| phy.createBox(bodies[0], border, maxY - minY + 2 * border), // Left | |
| phy.createBox(bodies[1], border, maxY - minY + 2 * border), // Right | |
| phy.createBox(bodies[2], maxX - minX + 2 * border, border), // Top | |
| phy.createBox(bodies[3], maxX - minX + 2 * border, border), // Bottom | |
| }; | |
| ker.add<WorldBounds>(ent, minX, maxX, minY, maxY, std::move(bodies), std::move(shapes)); | |
| } | |
| } | |
| ngMethod(add); | |
| }; | |
| // View | |
| struct ViewFollow { | |
| ngType(ViewFollow); | |
| static constexpr double defaultWidth = 800, defaultHeight = 450, defaultPadding = 180; | |
| double x = 0, y = 0; | |
| ngFields(x, y); | |
| double offsetX = 0, offsetY = 0; | |
| ngFields(offsetX, offsetY); | |
| double width = defaultWidth, height = defaultHeight; | |
| ngFields(width, height); | |
| double padding = defaultPadding; | |
| ngField(padding); | |
| double rate = 200; | |
| ngField(rate); | |
| static auto add(Kernel &ker, const Entity ent) -> void { | |
| if (ker.has<Position>(ent)) { | |
| auto &pos = ker.get<Position>(ent); | |
| ker.add<ViewFollow>(ent, pos.x, pos.y); | |
| } | |
| } | |
| }; | |
| // Player | |
| struct Player { | |
| ngType(Player); | |
| Graphics::Image footprints; | |
| static auto add(Kernel &ker, const Entity ent) -> void { | |
| auto &gfx = ker.ctx<Graphics>(); | |
| ker.add<Player>(ent, gfx.createImage(Platform::getAssetPath("footprints.png"))); | |
| } | |
| }; | |
| // | |
| // Triggers | |
| // | |
| // Physics | |
| struct PhysicsPre : Kernel::Trigger<> {}; | |
| struct PhysicsPost : Kernel::Trigger<> {}; | |
| // Draw | |
| struct ApplyView : Kernel::Trigger<> {}; | |
| struct Draw : Kernel::Trigger<> {}; | |
| struct DrawOverlay : Kernel::Trigger<> {}; | |
| // | |
| // Rules | |
| // | |
| // Sprite | |
| ngRule(Draw, DrawSprites)(Kernel &ker) { | |
| // Order sprites by depth and draw at positions | |
| ker.isort<Sprite>([](const Sprite &a, const Sprite &b) { | |
| return a.depth < b.depth; | |
| }); | |
| auto &gfx = ker.ctx<Graphics>(); | |
| ker.view<Sprite, Position>().each([&](const Sprite &spr, const Position &pos) { | |
| gfx.drawImage(spr.image, pos.x, pos.y, spr.scale); | |
| }); | |
| }; | |
| // Player | |
| ngRule(PhysicsPre, WalkPlayerToTouch)(Kernel &ker) { | |
| auto &ev = ker.ctx<Events>(); | |
| auto &touches = ev.getTouches(); | |
| if (touches.size() > 0) { | |
| // Add walk if needed, set target to first touch | |
| ker.view<Player>().each([&](const Entity ent, const Player &) { | |
| auto &walk = ker.has<Walk>(ent) ? ker.get<Walk>(ent) : ker.add<Walk>(ent); | |
| walk.target.setPosition({ touches[0].x, touches[0].y }); | |
| walk.touchTime = ker.tim.t(); | |
| }); | |
| } | |
| }; | |
| ngRule(PhysicsPost, CheckPlayerWalk)(Kernel &ker) { | |
| // Remove walk if reached target or obstructed | |
| ker.view<Player, Feet, Walk>().each( | |
| [&](const Entity ent, const Player &, const Feet &feet, const Walk &walk) { | |
| if (ker.tim.t() - walk.touchTime < 1) { // Recently touched | |
| return; | |
| } | |
| auto [vx, vy] = feet.body.getVelocity(); | |
| if (vx * vx + vy * vy >= 20 * 20) { // Moving fast enough | |
| return; | |
| } | |
| auto [fx, fy] = feet.body.getPosition(); | |
| auto [wx, wy] = walk.target.getPosition(); | |
| auto dx = wx - fx, dy = wy - fy; | |
| auto dLen = std::sqrt(dx * dx + dy * dy); | |
| if (dLen < 10) { // We're close, we'll probably get there | |
| return; | |
| } else if (dLen < 1) { // We're there! | |
| ker.remove<Walk>(ent); | |
| } | |
| // Remove if velocity along direction toward target is too low | |
| dx /= dLen; | |
| dy /= dLen; | |
| auto dot = vx * dx + vy * dy; | |
| if (dot <= 7) { | |
| ker.remove<Walk>(ent); | |
| } | |
| }); | |
| }; | |
| ngRule(PhysicsPost, ReadPlayerPhysics)(Kernel &ker) { | |
| // Read physics to position | |
| ker.view<Player, Feet, Position>().each([&](const Player &, const Feet &feet, Position &pos) { | |
| auto [x, y] = feet.body.getPosition(); | |
| pos.x = x - feet.offsetX; | |
| pos.y = y - feet.offsetY; | |
| }); | |
| }; | |
| ngRule(PhysicsPost, UpdatePlayerDepth)(Kernel &ker) { | |
| // Set player depth behind objects that obscure it | |
| auto &phy = ker.ctx<Physics>(); | |
| ker.view<Player, Feet, Sprite>().each([&](const Entity ent, const Player &, const Feet &feet, | |
| Sprite &spr) { | |
| spr.depth = 1000; | |
| const auto query = [&](double x, double y) { | |
| phy.segmentQuery({ x, y }, { x, y + 1e4 }, 1, [&](Physics::Shape &shape, Vec2, Vec2, double) { | |
| auto other = shape.getBody().getEntity(); | |
| if (other != Kernel::null && other != ent && ker.has<Sprite>(other)) { | |
| spr.depth = std::min(spr.depth, ker.get<Sprite>(other).depth - 0.2); | |
| } | |
| }); | |
| }; | |
| auto [x, y] = feet.body.getPosition(); | |
| query(x + 22, y); | |
| query(x - 22, y); | |
| query(x, y); | |
| }); | |
| }; | |
| ngRule(DrawOverlay, DrawPlayerFootprints)(Kernel &ker) { | |
| // Draw footprints at target when walking | |
| auto &gfx = ker.ctx<Graphics>(); | |
| ker.view<Player, Feet, Walk>().each([&](const Player &pl, const Feet &feet, const Walk &walk) { | |
| auto [fx, fy] = feet.body.getPosition(); | |
| auto [wx, wy] = walk.target.getPosition(); | |
| auto dx = fx - wx, dy = fy - wy; | |
| if (dx * dx + dy * dy > 30 * 30) { // Don't draw if target is too close | |
| gfx.drawImage(pl.footprints, wx, wy, 0.65); | |
| } | |
| }); | |
| }; | |
| // View | |
| ngRule(PhysicsPost, UpdateViewFollow)(Kernel &ker) { | |
| ker.view<ViewFollow, Position>().each([&](ViewFollow &vf, const Position &pos) { | |
| // See https://www.gamasutra.com/blogs/ScottLembcke/20180404/316046/Improved_Lerp_Smoothing.php | |
| auto dx = pos.x + vf.offsetX - vf.x, dy = pos.y + vf.offsetY - vf.y; | |
| if (abs(dx) > 0.5 * vf.width - vf.padding) { | |
| vf.x += pow(2, -vf.rate * ker.tim.dt()) * abs(dx) / dx | |
| * (abs(dx) - (0.5 * vf.width - vf.padding)); | |
| } | |
| if (abs(dy) > 0.5 * vf.height - vf.padding) { | |
| vf.y += pow(2, -vf.rate * ker.tim.dt()) * abs(dy) / dy | |
| * (abs(dy) - (0.5 * vf.height - vf.padding)); | |
| } | |
| ker.view<WorldBounds>().each([&](WorldBounds &wb) { | |
| if (vf.x - 0.5 * vf.width < wb.minX) { | |
| vf.x = wb.minX + 0.5 * vf.width; | |
| } | |
| if (vf.x + 0.5 * vf.width > wb.maxX) { | |
| vf.x = wb.maxX - 0.5 * vf.width; | |
| } | |
| if (vf.y - 0.5 * vf.height < wb.minY) { | |
| vf.y = wb.minY + 0.5 * vf.height; | |
| } | |
| if (vf.y + 0.5 * vf.height > wb.maxY) { | |
| vf.y = wb.maxY - 0.5 * vf.height; | |
| } | |
| }); | |
| }); | |
| }; | |
| ngRule(ApplyView, ApplyViewFollow)(Kernel &ker) { | |
| auto &gfx = ker.ctx<Graphics>(); | |
| ker.view<ViewFollow>().each([&](const ViewFollow &vf) { | |
| gfx.setView(vf.x, vf.y, vf.width, vf.height); | |
| }); | |
| }; | |
| // | |
| // Edit | |
| // | |
| ngRule(Edit::UpdateBoxes, EditBoxes)(Kernel &ker) { | |
| auto &edit = ker.ctx<Edit>(); | |
| ker.view<Position, Sprite>().each([&](const Entity ent, const Position &pos, const Sprite &spr) { | |
| auto [imgW, imgH] = spr.image.getSize(); | |
| edit.setBox(ent, pos.x, pos.y, spr.scale * imgW, spr.scale * imgH); | |
| }); | |
| ker.view<WorldBounds>(Kernel::exclude<Sprite>).each([&](const Entity ent, const WorldBounds &wb) { | |
| edit.setBox(ent, 0.5 * (wb.minX + wb.maxX), 0.5 * (wb.minY + wb.maxY), wb.maxX - wb.minX, | |
| wb.maxY - wb.minY); | |
| }); | |
| }; | |
| ngRule(Edit::Input, EditShape)(Kernel &ker) { | |
| auto &edit = ker.ctx<Edit>(); | |
| auto &mode = edit.getMode(); | |
| auto &ev = ker.ctx<Events>(); | |
| auto &touches = ev.getTouches(); | |
| if (mode == "shape") { | |
| auto view = ker.view<Edit::Select, Feet>(); | |
| if (view.size() == 0) { | |
| edit.setMode("select"); | |
| } else if (touches.size() == 1 && (touches[0].pressed || touches[0].released)) { | |
| auto &phy = ker.ctx<Physics>(); | |
| view.each([&](Feet &feet) { | |
| std::vector<Vec2> verts; | |
| for (auto nVerts = feet.shape.getNumVertices(), i = 0; i < nVerts; ++i) { | |
| auto v = feet.shape.getVertex(i); | |
| auto [wx, wy] = feet.body.toWorld(v); | |
| if (!(abs(touches[0].x - wx) < 2 && abs(touches[0].y - wy) < 2)) { | |
| verts.push_back(v); | |
| } | |
| } | |
| if (touches[0].released || verts.size() == 0) { | |
| verts.push_back(feet.body.toLocal({ touches[0].x, touches[0].y })); | |
| } | |
| feet.shape = phy.createPoly(feet.body, verts); | |
| }); | |
| if (touches[0].released) { | |
| edit.action("edit feet shape"); | |
| } | |
| } | |
| } | |
| }; | |
| ngRule(Edit::Draw, EditOverlay)(Kernel &ker) { | |
| auto &edit = ker.ctx<Edit>(); | |
| auto &mode = edit.getMode(); | |
| auto &gfx = ker.ctx<Graphics>(); | |
| if (mode == "shape") { | |
| // Feet shape and vertices for selected only | |
| gfx.setColor(0, 0, 0xff); | |
| ker.view<Edit::Select, Feet>().each([&](const Feet &feet) { | |
| for (auto nVerts = feet.shape.getNumVertices(), i = 0; i < nVerts; ++i) { | |
| auto [wx, wy] = feet.body.toWorld(feet.shape.getVertex(i)); | |
| gfx.drawRectangleFill(wx, wy, 4, 4); | |
| } | |
| feet.body.draw(gfx); | |
| }); | |
| } | |
| if (mode == "select") { | |
| // All feet shapes | |
| gfx.scope([&]() { | |
| gfx.setColor(0, 0, 0xff); | |
| ker.view<Feet>().each([&](const Feet &feet) { | |
| feet.body.draw(gfx); | |
| }); | |
| }); | |
| } | |
| }; | |
| auto Sprite_uiInspect(Sprite &spr, Kernel &ker, const Entity) -> void { | |
| auto &ui = ker.ctx<UI>(); | |
| ui.elem("img")("preview")("src", spr.image.getBlobUrl()); | |
| ui.div()("info")([&]() { | |
| ui.text("path: {}", spr.image.getPath()); | |
| }); | |
| ui.div()("info")([&]() { | |
| auto [imgW, imgH] = spr.image.getSize(); | |
| ui.text("width: {}, height: {}", imgW, imgH); | |
| }); | |
| } | |
| ngMethod(Sprite, uiInspect); | |
| auto Feet_uiInspect(Feet &feet, Kernel &ker, const Entity ent) -> void { | |
| auto &ui = ker.ctx<UI>(); | |
| auto &edit = ker.ctx<Edit>(); | |
| ui.div()("info")([&]() { | |
| ui.text("shape: {} vertices", feet.shape.getNumVertices()); | |
| if (edit.getEnabled() && !ker.has<Player>(ent)) { | |
| ui.button()("shape")("selected", edit.getMode() == "shape")("click", [&](emscripten::val) { | |
| edit.setMode(edit.getMode() == "shape" ? "select" : "shape"); | |
| }); | |
| } | |
| }); | |
| } | |
| ngMethod(Feet, uiInspect); | |
| // | |
| // Stage | |
| // | |
| struct Stage { | |
| explicit Stage(Kernel &ker_) | |
| : ker(ker_) { | |
| } | |
| auto getSceneName() -> const std::string & { | |
| return sceneName; | |
| } | |
| auto createPlayer(double x, double y) -> void { | |
| auto &gfx = ker.ctx<Graphics>(); | |
| auto &phy = ker.ctx<Physics>(); | |
| auto ent = ker.create(); | |
| ker.add<Player>(ent); | |
| ker.add<Sprite>(ent, gfx.createImage(Platform::getAssetPath("player.png")), 0.25, 1000.0); | |
| auto &pos = ker.add<Position>(ent, x, y); | |
| constexpr auto offsetY = 65.0; | |
| auto body | |
| = phy.createDynamic(1, INFINITY).setEntity(ent).setPosition({ pos.x, pos.y + offsetY }); | |
| constexpr auto radius = 8; | |
| auto shape = phy.createBox(body, 45 - 2 * radius, 20 - 2 * radius, radius); | |
| ker.add<Feet>(ent, std::move(body), std::move(shape), 0.0, offsetY); | |
| ker.add<Friction>(ent); | |
| ker.add<ViewFollow>(ent, pos.x, pos.y, 0.0, 30.0); | |
| } | |
| auto load(const std::string &sceneName_) -> void { | |
| sceneName = sceneName_; | |
| ker.clear(); | |
| Archive::fromFile(Platform::getAssetPath(sceneName + ".scn")).load(ker); | |
| createPlayer(230, 115); | |
| auto &edit = ker.ctx<Edit>(); | |
| edit.clearActions(); | |
| edit.action(fmt::format("load scene '{}'", sceneName)); | |
| } | |
| auto load(int d) -> void { | |
| auto i = std::distance( | |
| sceneNames.begin(), std::find(sceneNames.begin(), sceneNames.end(), sceneName)); | |
| load(sceneNames[(i + d) % sceneNames.size()]); | |
| } | |
| auto save() -> void { | |
| Archive ar; | |
| ar.save(ker, [&](const Entity ent) { | |
| return !ker.has<Player>(ent); | |
| }); | |
| #ifdef __EMSCRIPTEN__ | |
| emscripten::val::global("console").call<void>("log", ar.toString()); | |
| emscripten::val::global("window").call<void>( | |
| "alert", std::string("Please check the JavaScript console...")); | |
| #endif | |
| } | |
| auto convert(const std::string &path, double scale = 0.5) -> void { | |
| auto &gfx = ker.ctx<Graphics>(); | |
| auto depth = 0.0; | |
| auto ar = Archive::fromFile(path); | |
| ar.read([&](Archive::Reader &r) { | |
| r.each("objects", [&]() { | |
| auto type = r.str("type", "prop"); | |
| auto x = r.num("x", 0); | |
| auto y = r.num("y", 0); | |
| auto imagePath = Platform::getAssetPath(r.str("imageName", "player.png")); | |
| if (!std::strcmp(type, "prop")) { | |
| auto ent = ker.create(); | |
| auto &spr = ker.add<Sprite>(ent, gfx.createImage(imagePath), scale, depth++); | |
| auto [imgW, imgH] = spr.image.getSize(); | |
| ker.add<Position>(ent, scale * (x + 0.5 * imgW), scale * (y + 0.5 * imgH)); | |
| } | |
| }); | |
| }); | |
| } | |
| private: | |
| Kernel &ker; | |
| std::string sceneName; | |
| inline static auto sceneNames = []() { | |
| std::vector<std::string> result; | |
| for (auto &file : std::filesystem::directory_iterator(Platform::getAssetPath(""))) { | |
| if (file.path().extension() == ".scn") { | |
| result.push_back(file.path().stem().u8string()); | |
| } | |
| } | |
| return result; | |
| }(); | |
| }; | |
| // | |
| // main | |
| // | |
| auto main(int, char **) -> int { | |
| // Modules | |
| Timing tim; | |
| Graphics gfx("dream hotel"); | |
| UI ui(tim); | |
| Events ev(gfx); | |
| Physics phy(tim); | |
| Kernel ker(tim); | |
| Edit edit(ker, gfx, ui); | |
| Stage stage(ker); | |
| ker.ctx(gfx, ui, ev, phy, edit, stage); | |
| // Start! | |
| stage.load("pool"); | |
| // Loop | |
| ev.loop([&]() { | |
| // Timing | |
| tim.frame(); | |
| if (tim.dt() >= 3 * 1 / 60.0) { // Frame drop | |
| return; | |
| } | |
| // Unfocused? | |
| if (!ev.isWindowFocused()) { | |
| return; | |
| } | |
| // Logic | |
| if (edit.getEnabled()) { | |
| // Edit | |
| edit.frame(); | |
| } else { | |
| // Physics | |
| ker.run<PhysicsPre>(); | |
| phy.frame(); | |
| ker.run<PhysicsPost>(); | |
| } | |
| // Graphics | |
| gfx.frame([&]() { | |
| // Background color | |
| gfx.clear(0xcc, 0xe4, 0xf5); | |
| // Draw | |
| if (edit.getEnabled()) { | |
| edit.applyView(); | |
| } else { | |
| ker.run<ApplyView>(); | |
| } | |
| ker.run<Draw>(); | |
| ker.run<DrawOverlay>(); | |
| if (edit.getEnabled()) { | |
| edit.draw(); | |
| } | |
| }); | |
| // UI | |
| ui.frame([&]() { | |
| // Top | |
| ui.panel("top", [&]() { | |
| ui.div()("toolbar")([&]() { | |
| edit.uiToolbar(); | |
| if (edit.getEnabled()) { | |
| ui.div()("small-gap"); | |
| ui.div()("scene-switcher")([&]() { | |
| ui.button()("prev")("click", [&](emscripten::val) { | |
| stage.load(-1); | |
| }); | |
| ui.div()("name")([&]() { | |
| ui.text(stage.getSceneName()); | |
| }); | |
| ui.button()("next")("click", [&](emscripten::val) { | |
| stage.load(1); | |
| }); | |
| }); | |
| ui.button()("save")("click", [&](emscripten::val) { | |
| stage.save(); | |
| }); | |
| } | |
| }); | |
| }); | |
| // Bottom | |
| ui.panel("bottom", [&]() { | |
| ui.div()("status")([&]() { | |
| ui.button()("reload")("click", [&](emscripten::val) { | |
| emscripten::val::global("location").call<void>("reload"); | |
| }); | |
| ui.button()("profiler")("selected", tim.runningProf())("click", [&](emscripten::val) { | |
| tim.startProf(5); | |
| }); | |
| ui.div()([&]() { | |
| ui.text("fps: {}", int(round(tim.getFPS()))); | |
| }); | |
| ui.div()("spacer"); | |
| edit.uiStatus(); | |
| }); | |
| }); | |
| // Side | |
| ui.panel("side", [&]() { | |
| edit.uiInspect(); | |
| }); | |
| }); | |
| }); | |
| return 0; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment