summaryrefslogtreecommitdiff
path: root/Userland/Games/ColorLines
diff options
context:
space:
mode:
authorOleg Kosenkov <oleg@kosenkov.ca>2022-11-06 17:58:44 -0500
committerSam Atkins <atkinssj@gmail.com>2022-12-12 17:30:04 +0000
commit28bb3367cb11bf91aadf9d0b7b88957f7df85339 (patch)
tree6f190841a9d427db53de3849cab675a2f6b7f9db /Userland/Games/ColorLines
parentd987ddc0ee080e6c047ac8af4199625f417ba346 (diff)
downloadserenity-28bb3367cb11bf91aadf9d0b7b88957f7df85339.zip
Games: Add ColorLines
Diffstat (limited to 'Userland/Games/ColorLines')
-rw-r--r--Userland/Games/ColorLines/CMakeLists.txt13
-rw-r--r--Userland/Games/ColorLines/ColorLines.cpp400
-rw-r--r--Userland/Games/ColorLines/ColorLines.h90
-rw-r--r--Userland/Games/ColorLines/HueFilter.h51
-rw-r--r--Userland/Games/ColorLines/Marble.h46
-rw-r--r--Userland/Games/ColorLines/MarbleBoard.h356
-rw-r--r--Userland/Games/ColorLines/MarblePath.h64
-rw-r--r--Userland/Games/ColorLines/main.cpp75
8 files changed, 1095 insertions, 0 deletions
diff --git a/Userland/Games/ColorLines/CMakeLists.txt b/Userland/Games/ColorLines/CMakeLists.txt
new file mode 100644
index 0000000000..12ee78ddb5
--- /dev/null
+++ b/Userland/Games/ColorLines/CMakeLists.txt
@@ -0,0 +1,13 @@
+serenity_component(
+ ColorLines
+ RECOMMENDED
+ TARGETS ColorLines
+)
+
+set(SOURCES
+ ColorLines.cpp
+ main.cpp
+)
+
+serenity_app(ColorLines ICON app-colorlines)
+target_link_libraries(ColorLines PRIVATE LibGUI LibCore LibGfx LibConfig LibMain LibDesktop)
diff --git a/Userland/Games/ColorLines/ColorLines.cpp b/Userland/Games/ColorLines/ColorLines.cpp
new file mode 100644
index 0000000000..f8b4b095f3
--- /dev/null
+++ b/Userland/Games/ColorLines/ColorLines.cpp
@@ -0,0 +1,400 @@
+/*
+ * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca>
+ * Copyright (c) 2022, the SerenityOS developers.
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include "ColorLines.h"
+#include "HueFilter.h"
+#include "Marble.h"
+#include "MarbleBoard.h"
+#include <AK/String.h>
+#include <LibConfig/Client.h>
+#include <LibGUI/MessageBox.h>
+#include <LibGUI/Painter.h>
+#include <LibGfx/Font/Emoji.h>
+
+ColorLines::BitmapArray ColorLines::build_marble_color_bitmaps()
+{
+ auto marble_bitmap = MUST(Gfx::Bitmap::try_load_from_file("/res/icons/colorlines/colorlines.png"sv));
+ float constexpr hue_degrees[Marble::number_of_colors] = {
+ 0, // Red
+ 45, // Brown/Yellow
+ 90, // Green
+ 180, // Cyan
+ 225, // Blue
+ 300 // Purple
+ };
+ BitmapArray colored_bitmaps;
+ colored_bitmaps.ensure_capacity(Marble::number_of_colors);
+ for (int i = 0; i < Marble::number_of_colors; ++i) {
+ auto bitmap = MUST(marble_bitmap->clone());
+ HueFilter filter { hue_degrees[i] };
+ filter.apply(*bitmap, bitmap->rect(), *marble_bitmap, marble_bitmap->rect());
+ colored_bitmaps.append(bitmap);
+ }
+ return colored_bitmaps;
+}
+
+ColorLines::BitmapArray ColorLines::build_marble_trace_bitmaps()
+{
+ // Use "Paw Prints" Unicode Character (U+1F43E)
+ auto trace_bitmap = NonnullRefPtr<Gfx::Bitmap>(*Gfx::Emoji::emoji_for_code_point(0x1F43E));
+ BitmapArray result;
+ result.ensure_capacity(number_of_marble_trace_bitmaps);
+ result.append(trace_bitmap);
+ result.append(MUST(result.last()->rotated(Gfx::RotationDirection::Clockwise)));
+ result.append(MUST(result.last()->rotated(Gfx::RotationDirection::Clockwise)));
+ result.append(MUST(result.last()->rotated(Gfx::RotationDirection::Clockwise)));
+ return result;
+}
+
+ColorLines::ColorLines(StringView app_name)
+ : m_app_name { app_name }
+ , m_game_state { GameState::Idle }
+ , m_board { make<MarbleBoard>() }
+ , m_marble_bitmaps { build_marble_color_bitmaps() }
+ , m_trace_bitmaps { build_marble_trace_bitmaps() }
+ , m_score_font { Gfx::BitmapFont::load_from_file("/res/fonts/MarietaBold24.font") }
+{
+ VERIFY(m_marble_bitmaps.size() == Marble::number_of_colors);
+ set_font(Gfx::FontDatabase::default_fixed_width_font().bold_variant());
+ m_high_score = Config::read_i32(m_app_name, m_app_name, "HighScore"sv, 0);
+ reset();
+}
+
+void ColorLines::reset()
+{
+ set_game_state(GameState::StartingGame);
+}
+
+void ColorLines::mousedown_event(GUI::MouseEvent& event)
+{
+ if (m_game_state != GameState::Idle && m_game_state != GameState::MarbleSelected)
+ return;
+ auto const event_position = event.position().translated(
+ -frame_inner_rect().x(),
+ -frame_inner_rect().y() - board_vertical_margin);
+ if (event_position.x() < 0 || event_position.y() < 0)
+ return;
+ auto const clicked_cell = Point { event_position.x() / board_cell_dimension.width(),
+ event_position.y() / board_cell_dimension.height() };
+ if (!MarbleBoard::in_bounds(clicked_cell))
+ return;
+ if (m_board->has_selected_marble()) {
+ auto const selected_cell = m_board->selected_marble().position();
+ if (selected_cell == clicked_cell) {
+ m_board->reset_selection();
+ set_game_state(GameState::Idle);
+ return;
+ }
+ if (m_board->is_empty_cell_at(clicked_cell)) {
+ if (m_board->build_marble_path(selected_cell, clicked_cell, m_marble_path))
+ set_game_state(GameState::MarbleMoving);
+ return;
+ }
+ if (m_board->select_marble(clicked_cell))
+ set_game_state(GameState::MarbleSelected);
+ return;
+ }
+ if (m_board->select_marble(clicked_cell))
+ set_game_state(GameState::MarbleSelected);
+}
+
+void ColorLines::timer_event(Core::TimerEvent&)
+{
+ switch (m_game_state) {
+ case GameState::GeneratingMarbles:
+ update();
+ if (--m_marble_animation_frame < AnimationFrames::marble_generating_end) {
+ m_marble_animation_frame = AnimationFrames::marble_default;
+ set_game_state(GameState::CheckingMarbles);
+ }
+ break;
+
+ case GameState::MarbleSelected:
+ m_marble_animation_frame = (m_marble_animation_frame + 1) % AnimationFrames::number_of_marble_bounce_frames;
+ update();
+ break;
+
+ case GameState::MarbleMoving:
+ m_marble_animation_frame = (m_marble_animation_frame + 1) % AnimationFrames::number_of_marble_bounce_frames;
+ update();
+ if (m_marble_path.remaining_steps() != 1 && m_marble_animation_frame != AnimationFrames::marble_at_top)
+ break;
+ if (auto const point = m_marble_path.next_point(); m_marble_path.is_empty()) {
+ auto const color = m_board->selected_marble().color();
+ m_board->reset_selection();
+ m_board->set_color_at(point, color);
+ if (m_board->check_and_remove_marbles())
+ set_game_state(GameState::MarblesRemoving);
+ else
+ set_game_state(GameState::GeneratingMarbles);
+ }
+ break;
+
+ case GameState::MarblesRemoving:
+ update();
+ if (++m_marble_animation_frame > AnimationFrames::marble_removing_end) {
+ m_marble_animation_frame = AnimationFrames::marble_default;
+ m_score += 2 * m_board->removed_marbles().size();
+ set_game_state(GameState::Idle);
+ }
+ break;
+
+ case GameState::StartingGame:
+ case GameState::Idle:
+ case GameState::CheckingMarbles:
+ break;
+
+ case GameState::GameOver: {
+ stop_timer();
+ update();
+ StringBuilder text;
+ text.appendff("Your score is {}", m_score);
+ if (m_score > m_high_score) {
+ text.append("\nThis is a new high score!"sv);
+ Config::write_i32(m_app_name, m_app_name, "HighScore"sv, int(m_high_score = m_score));
+ }
+ GUI::MessageBox::show(window(),
+ text.string_view(),
+ "Game Over"sv,
+ GUI::MessageBox::Type::Information);
+ reset();
+ break;
+ }
+
+ default:
+ VERIFY_NOT_REACHED();
+ }
+}
+
+void ColorLines::paint_event(GUI::PaintEvent& event)
+{
+ GUI::Frame::paint_event(event);
+ GUI::Painter painter(*this);
+ painter.add_clip_rect(frame_inner_rect());
+ painter.add_clip_rect(event.rect());
+
+ auto paint_cell = [&](GUI::Painter& painter, Gfx::IntRect rect, int color, int animation_frame) {
+ painter.draw_rect(rect, Color::Black);
+ rect.shrink(0, 1, 1, 0);
+ painter.draw_line(rect.bottom_left(), rect.top_left(), Color::White);
+ painter.draw_line(rect.top_left(), rect.top_right(), Color::White);
+ painter.draw_line(rect.top_right(), rect.bottom_right(), Color::DarkGray);
+ painter.draw_line(rect.bottom_right(), rect.bottom_left(), Color::DarkGray);
+ rect.shrink(1, 1, 1, 1);
+ painter.draw_line(rect.bottom_left(), rect.top_left(), Color::LightGray);
+ painter.draw_line(rect.top_left(), rect.top_right(), Color::LightGray);
+ painter.draw_line(rect.top_right(), rect.bottom_right(), Color::MidGray);
+ painter.draw_line(rect.bottom_right(), rect.bottom_left(), Color::MidGray);
+ rect.shrink(1, 1, 1, 1);
+ painter.fill_rect(rect, tile_color);
+ rect.shrink(1, 1, 1, 1);
+ if (color >= 0 && color < Marble::number_of_colors) {
+ auto const source_rect = Gfx::IntRect { animation_frame * marble_pixel_size, 0, marble_pixel_size, marble_pixel_size };
+ painter.draw_scaled_bitmap(rect, *m_marble_bitmaps[color], source_rect,
+ 1.0f, Gfx::Painter::ScalingMode::BilinearBlend);
+ }
+ };
+
+ painter.set_font(*m_score_font);
+
+ // Draw board header with score, high score
+ auto board_header_size = frame_inner_rect().size();
+ board_header_size.set_height(board_vertical_margin);
+ auto const board_header_rect = Gfx::IntRect { frame_inner_rect().top_left(), board_header_size };
+ painter.fill_rect(board_header_rect, Color::Black);
+
+ auto const text_margin = 8;
+
+ // Draw score
+ auto const score_text = MUST(String::formatted("{:05}"sv, m_score));
+ auto text_width { m_score_font->width(score_text) };
+ auto const glyph_height = m_score_font->glyph_height();
+ auto const score_text_rect = Gfx::IntRect {
+ frame_inner_rect().top_left().translated(text_margin),
+ Gfx::IntSize { text_width, glyph_height }
+ };
+ painter.draw_text(score_text_rect, score_text, Gfx::TextAlignment::CenterLeft, text_color);
+
+ // Draw high score
+ auto const high_score_text = MUST(String::formatted("{:05}"sv, m_high_score));
+ text_width = m_score_font->width(high_score_text);
+ auto const high_score_text_rect = Gfx::IntRect {
+ frame_inner_rect().top_right().translated(-(text_margin + text_width), text_margin),
+ Gfx::IntSize { text_width, glyph_height }
+ };
+ painter.draw_text(high_score_text_rect, high_score_text, Gfx::TextAlignment::CenterLeft, text_color);
+
+ auto const cell_rect
+ = Gfx::IntRect(frame_inner_rect().top_left(), board_cell_dimension)
+ .translated(0, board_vertical_margin);
+
+ // Draw all cells and the selected marble if it exists
+ for (int y = 0; y < MarbleBoard::board_size.height(); ++y)
+ for (int x = 0; x < MarbleBoard::board_size.width(); ++x) {
+ auto const& destination_rect = cell_rect.translated(
+ x * board_cell_dimension.width(),
+ y * board_cell_dimension.height());
+ auto const point = Point { x, y };
+ auto const animation_frame = m_game_state == GameState::MarbleSelected && m_board->has_selected_marble()
+ && m_board->selected_marble().position() == point
+ ? m_marble_animation_frame
+ : AnimationFrames::marble_default;
+ paint_cell(painter, destination_rect, m_board->color_at(point), animation_frame);
+ }
+
+ // Draw preview marbles in the board
+ for (auto const& marble : m_board->preview_marbles()) {
+ auto const& point = marble.position();
+ if (m_marble_path.contains(point) || !m_board->is_empty_cell_at(point))
+ continue;
+ auto const& destination_rect = cell_rect.translated(
+ point.x() * board_cell_dimension.width(),
+ point.y() * board_cell_dimension.height());
+ auto get_animation_frame = [this]() -> int {
+ switch (m_game_state) {
+ case GameState::GameOver:
+ return AnimationFrames::marble_default;
+ case GameState::GeneratingMarbles:
+ case GameState::CheckingMarbles:
+ return m_marble_animation_frame;
+ default:
+ return AnimationFrames::marble_generating_start;
+ }
+ };
+ paint_cell(painter, destination_rect, marble.color(), get_animation_frame());
+ }
+
+ // Draw preview marbles in the board header
+ for (size_t i = 0; i < MarbleBoard::number_of_preview_marbles; ++i) {
+ auto const& marble = m_board->preview_marbles()[i];
+ auto const& destination_rect = cell_rect.translated(
+ int(i + 3) * board_cell_dimension.width(),
+ -board_vertical_margin)
+ .shrunken(10, 10);
+ paint_cell(painter, destination_rect, marble.color(), AnimationFrames::marble_preview);
+ }
+
+ // Draw moving marble
+ if (!m_marble_path.is_empty()) {
+ auto const point = m_marble_path.current_point();
+ auto const& destination_rect = cell_rect.translated(
+ point.x() * board_cell_dimension.width(),
+ point.y() * board_cell_dimension.height());
+ paint_cell(painter, destination_rect, m_board->selected_marble().color(), m_marble_animation_frame);
+ }
+
+ // Draw removing marble
+ if (m_game_state == GameState::MarblesRemoving)
+ for (auto const& marble : m_board->removed_marbles()) {
+ auto const& point = marble.position();
+ auto const& destination_rect = cell_rect.translated(
+ point.x() * board_cell_dimension.width(),
+ point.y() * board_cell_dimension.height());
+ paint_cell(painter, destination_rect, marble.color(), m_marble_animation_frame);
+ }
+
+ // Draw marble move trace
+ if (m_game_state == GameState::MarbleMoving && m_marble_path.remaining_steps() > 1) {
+ auto const trace_size = Gfx::IntSize { m_trace_bitmaps.first()->width(), m_trace_bitmaps.first()->height() };
+ auto const target_trace_size = Gfx::IntSize { 14, 14 };
+ auto const source_rect = Gfx::FloatRect(Gfx::IntPoint {}, trace_size);
+ for (size_t i = 0; i < m_marble_path.remaining_steps() - 1; ++i) {
+ auto const& current_step = m_marble_path[i];
+ auto const destination_rect = Gfx::IntRect(frame_inner_rect().top_left(), target_trace_size)
+ .translated(
+ current_step.x() * board_cell_dimension.width(),
+ board_vertical_margin + current_step.y() * board_cell_dimension.height())
+ .translated(
+ (board_cell_dimension.width() - target_trace_size.width()) / 2,
+ (board_cell_dimension.height() - target_trace_size.height()) / 2);
+ auto get_direction_bitmap_index = [&]() -> size_t {
+ auto const& previous_step = m_marble_path[i + 1];
+ if (previous_step.x() > current_step.x())
+ return 3;
+ if (previous_step.x() < current_step.x())
+ return 1;
+ if (previous_step.y() > current_step.y())
+ return 0;
+ return 2;
+ };
+ painter.draw_scaled_bitmap(destination_rect, *m_trace_bitmaps[get_direction_bitmap_index()], source_rect,
+ 1.0f, Gfx::Painter::ScalingMode::BilinearBlend);
+ }
+ }
+}
+
+void ColorLines::restart_timer(int milliseconds)
+{
+ stop_timer();
+ start_timer(milliseconds);
+}
+
+void ColorLines::set_game_state(GameState state)
+{
+ m_game_state = state;
+ switch (state) {
+ case GameState::StartingGame:
+ m_marble_path.reset();
+ m_board->reset();
+ m_score = 0;
+ m_marble_animation_frame = AnimationFrames::marble_default;
+ update();
+ if (m_board->update_preview_marbles(false))
+ set_game_state(GameState::GeneratingMarbles);
+ else
+ set_game_state(GameState::GameOver);
+ break;
+ case GameState::GeneratingMarbles:
+ m_board->reset_selection();
+ m_marble_animation_frame = AnimationFrames::marble_generating_start;
+ update();
+ if (m_board->ensure_all_preview_marbles_are_on_empty_cells())
+ restart_timer(TimerIntervals::generating_marbles);
+ else
+ set_game_state(GameState::GameOver);
+ break;
+ case GameState::MarblesRemoving:
+ m_marble_animation_frame = AnimationFrames::marble_removing_start;
+ update();
+ restart_timer(TimerIntervals::removing_marbles);
+ break;
+ case GameState::Idle:
+ m_marble_animation_frame = AnimationFrames::marble_default;
+ update();
+ if (m_board->ensure_all_preview_marbles_are_on_empty_cells() && m_board->has_empty_cells())
+ stop_timer();
+ else
+ set_game_state(GameState::GameOver);
+ break;
+ case GameState::MarbleSelected:
+ restart_timer(TimerIntervals::selected_marble);
+ m_marble_animation_frame = AnimationFrames::marble_default;
+ update();
+ break;
+ case GameState::CheckingMarbles:
+ m_marble_animation_frame = AnimationFrames::marble_default;
+ update();
+ if (!m_board->place_preview_marbles_on_board())
+ set_game_state(GameState::GameOver);
+ else if (m_board->check_and_remove_marbles())
+ set_game_state(GameState::MarblesRemoving);
+ else
+ set_game_state(GameState::Idle);
+ break;
+ case GameState::MarbleMoving:
+ restart_timer(TimerIntervals::moving_marble);
+ m_board->clear_color_at(m_board->selected_marble().position());
+ update();
+ break;
+ case GameState::GameOver:
+ m_marble_animation_frame = AnimationFrames::marble_default;
+ update();
+ break;
+ default:
+ VERIFY_NOT_REACHED();
+ }
+}
diff --git a/Userland/Games/ColorLines/ColorLines.h b/Userland/Games/ColorLines/ColorLines.h
new file mode 100644
index 0000000000..0197080720
--- /dev/null
+++ b/Userland/Games/ColorLines/ColorLines.h
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca>
+ * Copyright (c) 2022, the SerenityOS developers.
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include "MarblePath.h"
+#include <AK/NonnullRefPtr.h>
+#include <AK/RefPtr.h>
+#include <AK/Vector.h>
+#include <LibGUI/Frame.h>
+#include <LibGfx/Font/BitmapFont.h>
+#include <LibGfx/Forward.h>
+
+class MarbleBoard;
+
+class ColorLines : public GUI::Frame {
+ C_OBJECT(ColorLines);
+
+public:
+ virtual ~ColorLines() override = default;
+
+ void reset();
+
+private:
+ enum class GameState {
+ Idle = 0, // No marble is selected, waiting for marble selection
+ StartingGame, // Game is starting
+ GeneratingMarbles, // Three new marbles are being generated
+ MarbleSelected, // Marble is selected, waiting for the target cell selection
+ MarbleMoving, // Selected marble is moving to the target cell
+ MarblesRemoving, // Selected marble has completed the move and some marbles are being removed from the board
+ CheckingMarbles, // Checking whether marbles on the board form lines of 5 or more marbles
+ GameOver // Game is over
+ };
+
+ ColorLines(StringView app_name);
+
+ virtual void paint_event(GUI::PaintEvent&) override;
+ virtual void mousedown_event(GUI::MouseEvent&) override;
+ virtual void timer_event(Core::TimerEvent&) override;
+
+ void set_game_state(GameState state);
+ void restart_timer(int milliseconds);
+
+ using Point = Gfx::IntPoint;
+ using BitmapArray = Vector<NonnullRefPtr<Gfx::Bitmap>>;
+
+ StringView const m_app_name;
+ GameState m_game_state { GameState::Idle };
+ NonnullOwnPtr<MarbleBoard> m_board;
+ BitmapArray const m_marble_bitmaps;
+ BitmapArray const m_trace_bitmaps;
+ RefPtr<Gfx::BitmapFont> m_score_font;
+ MarblePath m_marble_path {};
+ int m_marble_animation_frame {};
+ unsigned m_score {};
+ unsigned m_high_score {};
+
+ static BitmapArray build_marble_color_bitmaps();
+ static BitmapArray build_marble_trace_bitmaps();
+
+ static constexpr auto marble_pixel_size { 40 };
+ static constexpr auto board_vertical_margin { 45 };
+ static constexpr auto board_cell_dimension = Gfx::IntSize { 48, 48 };
+ static constexpr auto number_of_marble_trace_bitmaps { 4 };
+ static constexpr auto tile_color { Color::from_rgb(0xc0c0c0) };
+ static constexpr auto text_color { Color::from_rgb(0x00a0ff) };
+
+ enum AnimationFrames {
+ marble_default = 0,
+ marble_at_top = 2,
+ marble_preview = 18,
+ marble_generating_start = 21,
+ marble_generating_end = 17,
+ marble_removing_start = 7,
+ marble_removing_end = 16,
+ number_of_marble_bounce_frames = 7
+ };
+
+ enum TimerIntervals {
+ generating_marbles = 80,
+ removing_marbles = 60,
+ selected_marble = 70,
+ moving_marble = 28
+ };
+};
diff --git a/Userland/Games/ColorLines/HueFilter.h b/Userland/Games/ColorLines/HueFilter.h
new file mode 100644
index 0000000000..71c70aa970
--- /dev/null
+++ b/Userland/Games/ColorLines/HueFilter.h
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca>
+ * Copyright (c) 2022, the SerenityOS developers.
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include <AK/Math.h>
+#include <LibGfx/Filters/MatrixFilter.h>
+
+// This filter is similar to LibGfx/Filters/HueRotateFilter.h, however it uses
+// a different formula (matrix) for hue rotation. This filter provides brighter
+// colors compared to the filter provided in LibGfx.
+class HueFilter : public Gfx::MatrixFilter {
+public:
+ HueFilter(float angle_degrees)
+ : Gfx::MatrixFilter(calculate_hue_rotate_matrix(angle_degrees))
+ {
+ }
+
+ virtual bool amount_handled_in_filter() const override
+ {
+ return true;
+ }
+
+ virtual StringView class_name() const override { return "HueFilter"sv; }
+
+private:
+ static FloatMatrix3x3 calculate_hue_rotate_matrix(float angle_degrees)
+ {
+ float const angle_rads = angle_degrees * (AK::Pi<float> / 180.0f);
+ float cos_angle = 0.;
+ float sin_angle = 0.;
+ AK::sincos(angle_rads, sin_angle, cos_angle);
+ return FloatMatrix3x3 {
+ float(cos_angle + (1.0f - cos_angle) / 3.0f),
+ float(1.0f / 3.0f * (1.0f - cos_angle) - sqrtf(1.0f / 3.0f) * sin_angle),
+ float(1.0f / 3.0f * (1.0f - cos_angle) + sqrtf(1.0f / 3.0f) * sin_angle),
+
+ float(1.0f / 3.0f * (1.0f - cos_angle) + sqrtf(1.0f / 3.0f) * sin_angle),
+ float(cos_angle + 1.0f / 3.0f * (1.0f - cos_angle)),
+ float(1.0f / 3.0f * (1.0f - cos_angle) - sqrtf(1.0f / 3.0f) * sin_angle),
+
+ float(1.0f / 3.0f * (1.0f - cos_angle) - sqrtf(1.0f / 3.0f) * sin_angle),
+ float(1.0f / 3.0f * (1.0f - cos_angle) + sqrtf(1.0f / 3.0f) * sin_angle),
+ float(cos_angle + 1.0f / 3.0f * (1.0f - cos_angle))
+ };
+ }
+};
diff --git a/Userland/Games/ColorLines/Marble.h b/Userland/Games/ColorLines/Marble.h
new file mode 100644
index 0000000000..5ec2238ad0
--- /dev/null
+++ b/Userland/Games/ColorLines/Marble.h
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca>
+ * Copyright (c) 2022, the SerenityOS developers.
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include <LibGfx/Point.h>
+
+class Marble final {
+public:
+ using Point = Gfx::IntPoint;
+ using Color = u8;
+
+ static constexpr int number_of_colors { 6 };
+ static constexpr Color empty_cell = NumericLimits<Color>::max();
+
+ Marble() = default;
+ Marble(Point position, Color color)
+ : m_position { position }
+ , m_color { color }
+ {
+ }
+
+ bool operator==(Marble const& other) const = default;
+
+ [[nodiscard]] constexpr Point position() const { return m_position; }
+
+ [[nodiscard]] constexpr Color color() const { return m_color; }
+
+private:
+ Point m_position {};
+ Color m_color {};
+};
+
+namespace AK {
+template<>
+struct Traits<Marble> : public GenericTraits<Marble> {
+ static unsigned hash(Marble const& marble)
+ {
+ return Traits<Marble::Point>::hash(marble.position());
+ }
+};
+}
diff --git a/Userland/Games/ColorLines/MarbleBoard.h b/Userland/Games/ColorLines/MarbleBoard.h
new file mode 100644
index 0000000000..f5550d69d1
--- /dev/null
+++ b/Userland/Games/ColorLines/MarbleBoard.h
@@ -0,0 +1,356 @@
+/*
+ * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca>
+ * Copyright (c) 2022, the SerenityOS developers.
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include "Marble.h"
+#include "MarblePath.h"
+#include <AK/Array.h>
+#include <AK/Function.h>
+#include <AK/HashTable.h>
+#include <AK/IterationDecision.h>
+#include <AK/NumericLimits.h>
+#include <AK/Queue.h>
+#include <AK/Random.h>
+#include <AK/Vector.h>
+#include <LibGfx/Point.h>
+#include <LibGfx/Size.h>
+
+class MarbleBoard final {
+public:
+ using Color = Marble::Color;
+ using Point = Gfx::IntPoint;
+ using PointArray = Vector<Point>;
+ using SelectedMarble = Marble;
+ using PreviewMarble = Marble;
+ using MarbleArray = Vector<Marble>;
+
+ static constexpr Gfx::IntSize board_size { 9, 9 };
+ static constexpr size_t number_of_preview_marbles = 3;
+ static constexpr Color empty_cell = Marble::empty_cell;
+
+ using PreviewMarbles = Array<PreviewMarble, number_of_preview_marbles>;
+
+ MarbleBoard()
+ {
+ reset();
+ }
+
+ ~MarbleBoard() = default;
+
+ MarbleBoard(MarbleBoard const&) = delete;
+
+ [[nodiscard]] bool has_empty_cells() const
+ {
+ bool result = false;
+ for_each_cell([&](Point point) {
+ result = is_empty_cell_at(point);
+ return result ? IterationDecision::Break : IterationDecision::Continue;
+ });
+ return result;
+ }
+
+ [[nodiscard]] PointArray get_empty_cells() const
+ {
+ PointArray result;
+ for_each_cell([&](Point point) {
+ if (is_empty_cell_at(point))
+ result.append(point);
+ return IterationDecision::Continue;
+ });
+ random_shuffle(result);
+ return result;
+ }
+
+ void set_preview_marble(size_t i, PreviewMarble const& marble)
+ {
+ VERIFY(i < number_of_preview_marbles);
+ m_preview_marbles[i] = marble;
+ }
+
+ [[nodiscard]] bool place_preview_marbles_on_board()
+ {
+ if (!ensure_all_preview_marbles_are_on_empty_cells())
+ return false;
+ for (auto const& marble : m_preview_marbles)
+ if (!place_preview_marble_on_board(marble))
+ return false;
+ return true;
+ }
+
+ [[nodiscard]] bool check_preview_marbles_are_valid()
+ {
+ // Check marbles pairwise and also check the board cell under this marble is empty
+ static_assert(number_of_preview_marbles == 3);
+ return m_preview_marbles[0].position() != m_preview_marbles[1].position() && m_preview_marbles[0].position() != m_preview_marbles[2].position()
+ && m_preview_marbles[1].position() != m_preview_marbles[2].position()
+ && is_empty_cell_at(m_preview_marbles[0].position())
+ && is_empty_cell_at(m_preview_marbles[1].position())
+ && is_empty_cell_at(m_preview_marbles[2].position());
+ }
+
+ [[nodiscard]] bool update_preview_marbles(bool use_current)
+ {
+ auto empty_cells = get_empty_cells();
+ for (size_t i = 0; i < number_of_preview_marbles; ++i) {
+ auto marble = m_preview_marbles[i];
+ // Check marbles pairwise and also check the board cell under this marble is empty
+ auto const is_valid_marble = [&]() {
+ switch (i) {
+ case 0:
+ return marble.position() != m_preview_marbles[1].position() && marble.position() != m_preview_marbles[2].position() && is_empty_cell_at(marble.position());
+ case 1:
+ return marble.position() != m_preview_marbles[0].position() && marble.position() != m_preview_marbles[2].position() && is_empty_cell_at(marble.position());
+ case 2:
+ return marble.position() != m_preview_marbles[0].position() && marble.position() != m_preview_marbles[1].position() && is_empty_cell_at(marble.position());
+ default:
+ VERIFY_NOT_REACHED();
+ }
+ };
+ if (use_current && is_valid_marble()) {
+ continue;
+ }
+ while (!empty_cells.is_empty()) {
+ auto const position = empty_cells.take_last();
+ Color const new_color = get_random_uniform(Marble::number_of_colors);
+ marble = Marble { position, new_color };
+ if (!is_valid_marble())
+ continue;
+ set_preview_marble(i, marble);
+ break;
+ }
+ if (empty_cells.is_empty())
+ return false;
+ }
+ return empty_cells.size() > 0;
+ }
+
+ [[nodiscard]] bool ensure_all_preview_marbles_are_on_empty_cells()
+ {
+ if (check_preview_marbles_are_valid())
+ return true;
+ return update_preview_marbles(true);
+ }
+
+ [[nodiscard]] Color color_at(Point point) const
+ {
+ VERIFY(in_bounds(point));
+ return m_board[point.y()][point.x()];
+ }
+
+ void set_color_at(Point point, Color color)
+ {
+ VERIFY(in_bounds(point));
+ m_board[point.y()][point.x()] = color;
+ }
+
+ void clear_color_at(Point point)
+ {
+ set_color_at(point, empty_cell);
+ }
+
+ [[nodiscard]] bool is_empty_cell_at(Point point) const
+ {
+ return color_at(point) == empty_cell;
+ }
+
+ [[nodiscard]] static bool in_bounds(Point point)
+ {
+ return point.x() >= 0 && point.x() < board_size.width() && point.y() >= 0 && point.y() < board_size.height();
+ }
+
+ [[nodiscard]] bool build_marble_path(Point from, Point to, MarblePath& path) const
+ {
+ path.reset();
+
+ if (from == to || !MarbleBoard::in_bounds(from) || !MarbleBoard::in_bounds(to)) {
+ return false;
+ }
+
+ struct Trace {
+ public:
+ using Value = u8;
+
+ Trace() { reset(); }
+
+ ~Trace() = default;
+
+ [[nodiscard]] Value operator[](Point point) const
+ {
+ return m_map[point.y()][point.x()];
+ }
+
+ Value& operator[](Point point)
+ {
+ return m_map[point.y()][point.x()];
+ }
+
+ void reset()
+ {
+ for (size_t y = 0; y < board_size.height(); ++y)
+ for (size_t x = 0; x < board_size.width(); ++x)
+ m_map[y][x] = NumericLimits<Value>::max();
+ }
+
+ private:
+ BoardMap m_map;
+ };
+
+ Trace trace;
+ trace[from] = 1;
+
+ Queue<Point> queue;
+ queue.enqueue(from);
+
+ auto add_path_point = [&](Point point, u8 value) {
+ if (MarbleBoard::in_bounds(point) && is_empty_cell_at(point) && trace[point] > value) {
+ trace[point] = value;
+ queue.enqueue(point);
+ }
+ };
+
+ constexpr Point connected_four_ways[4] = {
+ { 0, -1 }, // to the top
+ { 0, 1 }, // to the bottom
+ { -1, 0 }, // to the left
+ { 1, 0 } // to the right
+ };
+
+ while (!queue.is_empty()) {
+ auto current = queue.dequeue();
+ if (current == to) {
+ while (current != from) {
+ path.add_point(current);
+ for (auto delta : connected_four_ways)
+ if (auto next = current.translated(delta); MarbleBoard::in_bounds(next) && trace[next] < trace[current]) {
+ current = next;
+ break;
+ }
+ }
+ path.add_point(current);
+ return true;
+ }
+ for (auto delta : connected_four_ways)
+ add_path_point(current.translated(delta), trace[current] + 1);
+ }
+ return false;
+ }
+
+ [[nodiscard]] bool check_and_remove_marbles()
+ {
+ m_removed_marbles.clear();
+ constexpr Point connected_four_ways[] = {
+ { -1, 0 }, // to the left
+ { 0, -1 }, // to the top
+ { -1, -1 }, // to the top-left
+ { 1, -1 } // to the top-right
+ };
+ HashTable<Marble, Traits<Marble>> marbles;
+ for_each_cell([&](Point current_point) {
+ if (is_empty_cell_at(current_point))
+ return IterationDecision::Continue;
+ auto const color { color_at(current_point) };
+ for (auto direction : connected_four_ways) {
+ size_t marble_count = 0;
+ for (auto p = current_point; in_bounds(p) && color_at(p) == color; p.translate_by(direction))
+ ++marble_count;
+ if (marble_count >= number_of_marbles_to_remove)
+ for (auto p = current_point; in_bounds(p) && color_at(p) == color; p.translate_by(direction))
+ marbles.set({ p, color });
+ }
+ return IterationDecision::Continue;
+ });
+ m_removed_marbles.ensure_capacity(marbles.size());
+ for (auto const& marble : marbles) {
+ m_removed_marbles.append(marble);
+ clear_color_at(marble.position());
+ }
+ return !m_removed_marbles.is_empty();
+ }
+
+ [[nodiscard]] PreviewMarbles const& preview_marbles() const
+ {
+ return m_preview_marbles;
+ }
+
+ [[nodiscard]] bool has_selected_marble() const
+ {
+ return m_selected_marble != nullptr;
+ }
+
+ [[nodiscard]] SelectedMarble const& selected_marble() const
+ {
+ VERIFY(has_selected_marble());
+ return *m_selected_marble;
+ }
+
+ [[nodiscard]] bool select_marble(Point point)
+ {
+ if (!is_empty_cell_at(point)) {
+ m_selected_marble = make<SelectedMarble>(point, color_at(point));
+ return true;
+ }
+ return false;
+ }
+
+ void reset_selection()
+ {
+ m_selected_marble.clear();
+ }
+
+ [[nodiscard]] MarbleArray const& removed_marbles() const
+ {
+ return m_removed_marbles;
+ }
+
+ void reset()
+ {
+ reset_selection();
+ for (size_t i = 0; i < number_of_preview_marbles; ++i)
+ m_preview_marbles[i] = { { 0, 0 }, empty_cell };
+ m_removed_marbles.clear();
+ for_each_cell([&](Point point) {
+ set_color_at(point, empty_cell);
+ return IterationDecision::Continue;
+ });
+ }
+
+private:
+ static void for_each_cell(Function<IterationDecision(Point)> functor)
+ {
+ for (int y = 0; y < board_size.height(); ++y)
+ for (int x = 0; x < board_size.width(); ++x)
+ if (functor({ x, y }) == IterationDecision::Break)
+ return;
+ }
+
+ [[nodiscard]] bool place_preview_marble_on_board(PreviewMarble const& marble)
+ {
+ if (!is_empty_cell_at(marble.position()))
+ return false;
+ set_color_at(marble.position(), marble.color());
+ return true;
+ }
+
+ static void random_shuffle(PointArray& points)
+ {
+ // Using Fisher–Yates in-place shuffle
+ if (points.size() > 1)
+ for (size_t i = points.size() - 1; i > 1; --i)
+ swap(points[i], points[get_random_uniform(i + 1)]);
+ }
+
+ static constexpr int number_of_marbles_to_remove { 5 };
+
+ using Row = Array<Color, board_size.width()>;
+ using BoardMap = Array<Row, board_size.height()>;
+
+ BoardMap m_board;
+ PreviewMarbles m_preview_marbles;
+ MarbleArray m_removed_marbles;
+ OwnPtr<SelectedMarble> m_selected_marble {};
+};
diff --git a/Userland/Games/ColorLines/MarblePath.h b/Userland/Games/ColorLines/MarblePath.h
new file mode 100644
index 0000000000..e6d178e988
--- /dev/null
+++ b/Userland/Games/ColorLines/MarblePath.h
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca>
+ * Copyright (c) 2022, the SerenityOS developers.
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include <AK/Vector.h>
+#include <LibGfx/Point.h>
+
+class MarblePath final {
+public:
+ using Point = Gfx::IntPoint;
+
+ MarblePath() = default;
+
+ void add_point(Point point)
+ {
+ m_path.append(point);
+ }
+
+ [[nodiscard]] bool is_empty() const
+ {
+ return m_path.is_empty();
+ }
+
+ [[nodiscard]] bool contains(Point point) const
+ {
+ return m_path.contains_slow(point);
+ }
+
+ [[nodiscard]] size_t remaining_steps() const
+ {
+ return m_path.size();
+ }
+
+ [[nodiscard]] Point current_point() const
+ {
+ VERIFY(!m_path.is_empty());
+ return m_path.last();
+ }
+
+ [[nodiscard]] Point next_point()
+ {
+ auto const point = current_point();
+ m_path.resize(m_path.size() - 1);
+ return point;
+ }
+
+ [[nodiscard]] Point operator[](size_t index) const
+ {
+ return m_path[index];
+ }
+
+ void reset()
+ {
+ m_path.clear();
+ }
+
+private:
+ Vector<Point> m_path;
+};
diff --git a/Userland/Games/ColorLines/main.cpp b/Userland/Games/ColorLines/main.cpp
new file mode 100644
index 0000000000..163a39ea21
--- /dev/null
+++ b/Userland/Games/ColorLines/main.cpp
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca>
+ * Copyright (c) 2022, the SerenityOS developers.
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include "ColorLines.h"
+#include <AK/URL.h>
+#include <LibConfig/Client.h>
+#include <LibCore/System.h>
+#include <LibDesktop/Launcher.h>
+#include <LibGUI/Action.h>
+#include <LibGUI/Application.h>
+#include <LibGUI/Icon.h>
+#include <LibGUI/Menu.h>
+#include <LibGUI/Menubar.h>
+#include <LibGUI/Window.h>
+#include <LibMain/Main.h>
+
+ErrorOr<int> serenity_main(Main::Arguments arguments)
+{
+ TRY(Core::System::pledge("stdio rpath recvfd sendfd unix"));
+
+ auto app = TRY(GUI::Application::try_create(arguments));
+
+ auto const app_name = "ColorLines"sv;
+ auto const title = "Color Lines"sv;
+ auto const man_file = "/usr/share/man/man6/ColorLines.md"sv;
+
+ Config::pledge_domain(app_name);
+
+ TRY(Desktop::Launcher::add_allowed_handler_with_only_specific_urls("/bin/Help", { URL::create_with_file_scheme(man_file) }));
+ TRY(Desktop::Launcher::seal_allowlist());
+
+ TRY(Core::System::pledge("stdio rpath recvfd sendfd"));
+
+ TRY(Core::System::unveil("/tmp/session/%sid/portal/launch", "rw"));
+ TRY(Core::System::unveil("/res", "r"));
+ TRY(Core::System::unveil(nullptr, nullptr));
+
+ auto app_icon = TRY(GUI::Icon::try_create_default_icon("app-colorlines"sv));
+
+ auto window = TRY(GUI::Window::try_create());
+
+ window->set_double_buffering_enabled(false);
+ window->set_title(title);
+ window->resize(436, 481);
+ window->set_resizable(false);
+
+ auto game = TRY(window->try_set_main_widget<ColorLines>(app_name));
+
+ auto game_menu = TRY(window->try_add_menu("&Game"));
+
+ TRY(game_menu->try_add_action(GUI::Action::create("&New Game", { Mod_None, Key_F2 }, TRY(Gfx::Bitmap::try_load_from_file("/res/icons/16x16/reload.png"sv)), [&](auto&) {
+ game->reset();
+ })));
+ TRY(game_menu->try_add_separator());
+ TRY(game_menu->try_add_action(GUI::CommonActions::make_quit_action([](auto&) {
+ GUI::Application::the()->quit();
+ })));
+
+ auto help_menu = TRY(window->try_add_menu("&Help"));
+ TRY(help_menu->try_add_action(GUI::CommonActions::make_command_palette_action(window)));
+ TRY(help_menu->try_add_action(GUI::CommonActions::make_help_action([&man_file](auto&) {
+ Desktop::Launcher::open(URL::create_with_file_scheme(man_file), "/bin/Help");
+ })));
+ TRY(help_menu->try_add_action(GUI::CommonActions::make_about_action(title, app_icon, window)));
+
+ window->show();
+
+ window->set_icon(app_icon.bitmap_for_size(16));
+
+ return app->exec();
+}